2 Incheckningar 8da59d54fd ... e50b84e480

Upphovsman SHA1 Meddelande Datum
  Harlan J. Iverson e50b84e480 updates to lib 4 månader sedan
  Harlan J. Iverson 3e48b1e34e upadated extensions 4 månader sedan

+ 273 - 3
extensions/bitchute_facade.py

@@ -1,3 +1,263 @@
+import requests
+
+import hogumathi_app.content_system as h_cs
+import hogumathi_app.view_model as h_vm
+
+VIDEOS_APP="http://localhost:5006"
+
+def get_video_collections ():
+    url = f'{VIDEOS_APP}/channels.json'
+    
+    resp = requests.get(url)
+    
+    return resp.json()
+
+def get_video_collection (collection_id):
+    url = f'{VIDEOS_APP}/channel/{collection_id}/videos.json'
+    
+    resp = requests.get(url)
+    
+    coll_data = resp.json()['data']
+    
+    def to_feed_item (c):
+        if c['media']['is_archived']:
+            video = h_vm.MediaItem(
+                type = 'video',
+                preview_image_url = c['poster'],
+                url = c['media']['url'],
+                content_type = c['media']['type'],
+                width = c['media']['width'] or 480,
+                height = c['media']['height'] or 360,
+                duration_ms = int(c['duration']) * 1000
+            )
+        else:
+            video = h_vm.MediaItem(
+                type = 'video',
+                preview_image_url = c['poster'],
+                url = c['media']['embed_url'],
+                content_type = c['media']['type'],
+                width = c['media']['width'] or 480,
+                height = c['media']['height'] or 360,
+                duration_ms = int(c['duration']) * 1000
+            )
+        fi = h_vm.FeedItem(
+            id = c['guid'],
+            handle = coll_data['id'],
+            display_name = c['author'],
+            created_at = c['pub_date'],
+            videos = [video],
+            
+            title = c['title'],
+            text = c['description'],
+            url = c['link'],
+            author_url = c['author_url'],
+            source_url = c['source_link']
+        )
+        
+        return fi
+    
+    feed_items = list(map(to_feed_item, coll_data['items']))
+    
+    return feed_items
+
+def test_video_collection ():
+    colls = get_video_collections()
+    
+    print(colls['data'][32].keys())
+    
+    coll = get_video_collection(colls['data'][32]['id'])
+    
+    return coll
+    
+    #print(coll['data']['items'][1].keys())
+    #print(coll['data']['items'][1]['media'])
+
+def search_videos_cards_collection (q, collection_id):
+    # FIXME cards may not be the best format but it's all we support
+    url = f'{VIDEOS_APP}/videos/search.cards.json'
+    params = dict(
+        channel = collection_id,
+        q = q
+    )
+    
+    # card logic from search note cards.
+    resp = requests.get(url, params=params)
+    
+    if resp.status_code != 200:
+        return
+    
+    resp_json = resp.json()
+    
+    cards = resp_json['cards']
+    
+    
+    def to_feed_item (search_result):
+        # different from normal collection
+        
+        # FIXME this uses Os-specific path sep
+        channel_and_video_id = search_result['id']
+        
+        # FIXME not provided by videos_app yet
+        #video_channel = search_result['channel']
+        
+        c = search_result['card']
+        
+        card_id = c['id']
+        
+        # work-around since search result ID also includes channel
+        video_id = card_id
+        
+        #content_url = f'{NOTES_APP}/notes/{collection_id}.html#note={note_id}&card={card_id}'
+        
+        # FIXME we need to update notes app to include the collection_id
+        #content_url = c['content_source'].replace(':notes-app', NOTES_APP)
+        #content_url += f'&card={card_id}'
+        
+        content_url = f'{VIDEOS_APP}/channel/{collection_id}/video/{video_id}.html'
+        source_url = c['content_source']
+        
+        fi = h_vm.FeedItem(
+            id = card_id,
+            display_name = "Notes App",
+            handle = collection_id,
+            created_at = c['created_at'],
+            
+            title = c['title'],
+            text = c['content'],
+            url = content_url,
+            source_url = source_url
+        )
+        return fi
+    
+    feed_items = list(map(to_feed_item, cards))
+    
+    return feed_items
+
+NOTES_APP = 'http://localhost:5000'
+
+def get_note_cards_collection (note_id, collection_id='daily'):
+    """
+    This is a good use case where we could directly query models,
+    
+    Rather than over HTTP.
+    
+    Store or Service Layer needs to support multi-process locking or similar.
+    """
+    url = f'{NOTES_APP}/{collection_id}/note/{note_id}/cards'
+    
+    resp = requests.get(url)
+    
+    if resp.status_code != 200:
+        return
+    
+    resp_json = resp.json()
+    
+    cards = resp_json['cards']
+    
+    def to_feed_item (c):
+        card_id = c['id']
+        note_id = c['note_id']
+        
+        content_url = f'{NOTES_APP}/notes/{collection_id}.html#note={note_id}&card={card_id}'
+        
+        # FIXME we need to update notes app to include the collection_id
+        #content_url = c['content_source'].replace(':notes-app', NOTES_APP)
+        #content_url += f'&card={card_id}'
+        
+        fi = h_vm.FeedItem(
+            id = card_id,
+            display_name = "Notes App",
+            handle = collection_id,
+            created_at = c['created_at'],
+            
+            title = c['title'],
+            text = c['content'],
+            url = content_url
+        )
+        return fi
+    
+    feed_items = list(map(to_feed_item, cards))
+    
+    return feed_items
+
+#@h_cs.query("notes:cards:search", weight=1000)
+def search_note_cards_collection (q, collection_id='daily'):
+    url = f'{NOTES_APP}/{collection_id}/notes/cards/search'
+    params = dict(
+        q = q
+    )
+    
+    resp = requests.get(url, params=params)
+    
+    if resp.status_code != 200:
+        return
+    
+    resp_json = resp.json()
+    
+    cards = resp_json['cards']
+    
+    
+    def to_feed_item (search_result):
+        # different from normal collection
+        c = search_result['card']
+        
+        # FIXME we should add this to notes_app
+        #note_id = search_result['id']
+        
+        card_id = c['id']
+        note_id = c['note_id']
+        
+        content_url = f'{NOTES_APP}/notes/{collection_id}.html#note={note_id}&card={card_id}'
+        
+        # FIXME we need to update notes app to include the collection_id
+        #content_url = c['content_source'].replace(':notes-app', NOTES_APP)
+        #content_url += f'&card={card_id}'
+        
+        fi = h_vm.FeedItem(
+            id = card_id,
+            display_name = "Notes App",
+            handle = collection_id,
+            created_at = c['created_at'],
+            
+            title = c['title'],
+            text = c['content'],
+            url = content_url
+        )
+        return fi
+    
+    feed_items = list(map(to_feed_item, cards))
+    
+    return feed_items
+
+#@h_cs.command("notes:cards:collection:prepend:<note_id>", weight=1000)
+def post_notes_card_prepend (note_id, text, collection_id='daily', should_create=True):
+    """
+    This is no different than posting a Tweet.
+    
+    Logically adds an item to the beginning of the collection.
+    
+    In the near future the intents will be manually approved but for now it's just a normal write.
+    
+    We might need to add a token of some sort as well for auth.
+    """
+    
+    
+    url = f'{NOTES_APP}/{collection_id}/intent/prepend-text/{note_id}'
+    
+    params = dict(
+        text = text,
+        should_create = should_create
+    )
+    
+    resp = requests.get(url, params=params)
+    
+    return
+
+def get_librivox_books ():
+    """
+    https://librivox.org/api/info
+    """
+    pass
 
 
 def register_content_sources ():
 def register_content_sources ():
     """
     """
@@ -8,7 +268,17 @@ def register_content_sources ():
     bitchute:videos:channel:
     bitchute:videos:channel:
     bitchute:comments:video:
     bitchute:comments:video:
     """
     """
-    pass
+    
+    h_cs.register_content_source("notes:cards:collection:", get_note_cards_collection, id_pattern="([^:]+)")
+    h_cs.register_content_source("notes:cards:search", search_note_cards_collection, id_pattern="")
+    #h_cs.register_content_command("notes:cards:collection:prepend", post_notes_card_prepend)
+    #h_cs.register_content_query("notes:cards:search", search_note_cards_collection, id_pattern="")
+    #
+    
+    h_cs.register_content_source("videos:collection:", get_video_collection, id_pattern="([^:]+)")
+    h_cs.register_content_source("videos:cards:search", search_videos_cards_collection, id_pattern="")
+    
+    
 
 
 def get_bitchute_comments ():
 def get_bitchute_comments ():
     """
     """
@@ -259,5 +529,5 @@ def get_bitchute_comments ():
 }
 }
     
     
     """
     """
-	
-	pass
+    
+    pass

+ 8 - 0
extensions/instagram_embed_facade.py

@@ -89,6 +89,10 @@ def tweet_model_dc_vm (includes: TweetExpansions, tweet: Tweet, me, my_url_for=u
         'view_conversation': FeedItemAction('twitter_v2_facade.get_tweet_html', {'tweet_id': tweet.conversation_id, 'view': 'conversation'}),
         'view_conversation': FeedItemAction('twitter_v2_facade.get_tweet_html', {'tweet_id': tweet.conversation_id, 'view': 'conversation'}),
     }
     }
     
     
+    setattr(actions['view_replies'], 'url', url_for(actions['view_replies'].route, **actions['view_replies'].route_params))
+    setattr(actions['view_thread'], 'url', url_for(actions['view_thread'].route, **actions['view_thread'].route_params))
+    setattr(actions['view_conversation'], 'url', url_for(actions['view_conversation'].route, **actions['view_conversation'].route_params))
+    
     if reply_depth:
     if reply_depth:
         vr = actions['view_replies']
         vr = actions['view_replies']
         url = my_url_for(vr.route, **vr.route_params)
         url = my_url_for(vr.route, **vr.route_params)
@@ -106,19 +110,23 @@ def tweet_model_dc_vm (includes: TweetExpansions, tweet: Tweet, me, my_url_for=u
         actions.update(
         actions.update(
             retweet = FeedItemAction('twitter_v2_facade.post_tweet_retweet', {'tweet_id': tweet.id})
             retweet = FeedItemAction('twitter_v2_facade.post_tweet_retweet', {'tweet_id': tweet.id})
             )
             )
+        setattr(actions['retweet'], 'url', my_url_for(actions['retweet'].route, actions['retweet'].route_params))
         if is_bookmarked:
         if is_bookmarked:
             actions.update(
             actions.update(
                 delete_bookmark = FeedItemAction('twitter_v2_facade.delete_tweet_bookmark', {'tweet_id': tweet.id})
                 delete_bookmark = FeedItemAction('twitter_v2_facade.delete_tweet_bookmark', {'tweet_id': tweet.id})
                 )
                 )
+            setattr(actions['delete_bookmark'], 'url', my_url_for(actions['delete_bookmark'].route, **actions['delete_bookmark'].route_params))
         else:
         else:
             actions.update(
             actions.update(
                 bookmark = FeedItemAction('twitter_v2_facade.post_tweet_bookmark', {'tweet_id': tweet.id})
                 bookmark = FeedItemAction('twitter_v2_facade.post_tweet_bookmark', {'tweet_id': tweet.id})
                 )
                 )
+            setattr(actions['bookmark'], 'url', my_url_for(actions['bookmark'].route, **actions['bookmark'].route_params))
     
     
     if my_g.get('twitter_live_enabled'):
     if my_g.get('twitter_live_enabled'):
         actions.update(
         actions.update(
             view_activity = FeedItemAction('twitter_v2_live_facade.get_tweet_activity_html', {'tweet_id': tweet.id})
             view_activity = FeedItemAction('twitter_v2_live_facade.get_tweet_activity_html', {'tweet_id': tweet.id})
             )
             )
+        setattr(actions['view_activity'], 'url', my_url_for(actions['view_activity'].route, **actions['view_activity'].route_params))
     
     
     
     
 
 

+ 43 - 1
hogumathi_app/__init__.py

@@ -129,6 +129,13 @@ else:
     visjs_enabled = False
     visjs_enabled = False
 
 
 
 
+if find_spec('socialdata_facade'):
+    import socialdata_facade
+    socialdata_enabled = True
+else:
+    print('socialdata module not found.')
+    socialdata_enabled = False
+
 """
 """
 By default we use embed because it's less likely to get banned, because it does not
 By default we use embed because it's less likely to get banned, because it does not
 use scraping. Once we've used scraping for a while and not been banned we can switch to that.
 use scraping. Once we've used scraping for a while and not been banned we can switch to that.
@@ -161,7 +168,39 @@ def record_content_analytics (content_id, content):
     else:
     else:
         print(f'record_content_analytics: unknown type: {content_id}')
         print(f'record_content_analytics: unknown type: {content_id}')
 
 
+def browser_native_messaging ():
+    # stdin = binary stream. ((payload length, payload), ...)
+    
+    print('hey. awaiting msg.')
+    
+    # "Note To write or read binary data from/to the standard streams, 
+    # use the underlying binary buffer object. For example, to write bytes to stdout, use sys.stdout.buffer.write(b'abc')."
+    # https://docs.python.org/3/library/sys.html#sys.stdin
+    
+    stdin = sys.stdin
+    
+    msg_len = read_int(stdin.buffer) # example read_int is in Apple Notes exporter code
+    while msg_len:
+        
+        print(f'new msg_len: {msg_len}')
+        
+        msg_bytes = stdin.buffer.read(msg_len)
+        msg = json.loads(msg_bytes.decode('utf-8'))
+        
+        print(msg)
+        
+        print('awaiting next msg...')
+        msg_len = read_int(stdin.buffer)
+        
+        return 0
+
 if __name__ == '__main__':
 if __name__ == '__main__':
+    if len(sys.argv) >= 2 and 	sys.argv[1] == 'browser_native_messaging':
+        print('browser_native_messaging')
+        
+        exit(browser_native_messaging())
+        
+    
     glitch_enabled = os.environ.get('PROJECT_DOMAIN') and True
     glitch_enabled = os.environ.get('PROJECT_DOMAIN') and True
     
     
     t = os.environ.get('BEARER_TOKEN')
     t = os.environ.get('BEARER_TOKEN')
@@ -335,7 +374,10 @@ if __name__ == '__main__':
         
         
     if bitchute_enabled:
     if bitchute_enabled:
         bitchute_facade.register_content_sources()
         bitchute_facade.register_content_sources()
-        
+    
+    if socialdata_enabled:
+        socialdata_facade.register_content_sources()
+    
     #CORS(api)
     #CORS(api)
     
     
     sched_app = h_sched.ScheduleApplication()
     sched_app = h_sched.ScheduleApplication()

+ 5 - 0
hogumathi_app/content_system.py

@@ -6,6 +6,11 @@ Generally a source returns a CollectionPage or individual items.
 
 
 At present many sources return a List of Maps because the design is being discovered and solidified as it makes sense rather than big design up front.
 At present many sources return a List of Maps because the design is being discovered and solidified as it makes sense rather than big design up front.
 
 
+Sources are sorted by weight and then checked for prefix and ID match,
+And the first source to return content in that order is used.
+Eg. online sources return no content for some reason so a cache is used.
+
+
 May end up similar to Android's ContentProvider, found later. I was 
 May end up similar to Android's ContentProvider, found later. I was 
 thinking about using content:// URI scheme.
 thinking about using content:// URI scheme.
 
 

+ 1 - 1
hogumathi_app/item_collections.py

@@ -68,7 +68,7 @@
 	</p>
 	</p>
 	{% if tweet.actions.view_replied_tweet %}
 	{% if tweet.actions.view_replied_tweet %}
 	<p class="reply_to w-100" style="border: 1px solid silver; padding: 6px">
 	<p class="reply_to w-100" style="border: 1px solid silver; padding: 6px">
-		<a href="{{ url_for(tweet.actions.view_replied_tweet.route, **tweet.actions.view_replies.route_params) }}">View in Thread</a>.
+		<a href="{{ tweet.actions.view_replied_tweet.url }}">View in Thread</a>.
 	</p>
 	</p>
 	{% endif %}
 	{% endif %}
 	{% endif %}
 	{% endif %}

+ 7 - 7
hogumathi_app/templates/partial/tweet-thread-children.html

@@ -144,7 +144,7 @@ function feed_item_to_activity (fi) {
 		
 		
 		
 		
 		{% if tweet.actions.view_replies %}
 		{% if tweet.actions.view_replies %}
-		<a href="{{ url_for(tweet.actions.view_replies.route, **tweet.actions.view_replies.route_params) }}">replies</a>
+		<a href="{{ tweet.actions.view_replies.url }}">replies</a>
 		|
 		|
 		{% endif %}
 		{% endif %}
 		
 		
@@ -154,31 +154,31 @@ function feed_item_to_activity (fi) {
 		{% if show_thread_controls and tweet.conversation_id %}
 		{% if show_thread_controls and tweet.conversation_id %}
 		{% with tweet=tweets[0] %}
 		{% with tweet=tweets[0] %}
 		{% if tweet.actions.view_thread %}
 		{% if tweet.actions.view_thread %}
-		<a href="{{ url_for(tweet.actions.view_thread.route, **tweet.actions.view_thread.route_params) }}">author thread</a>
+		<a href="{{ tweet.actions.view_thread.url }}">author thread</a>
 		|
 		|
 		{% endif %}
 		{% endif %}
 		{% if tweet.actions.view_conversation %}
 		{% if tweet.actions.view_conversation %}
-		<a href="{{ url_for(tweet.actions.view_conversation.route, **tweet.actions.view_conversation.route_params) }}">full convo</a>
+		<a href="{{ tweet.actions.view_conversation.url }}">full convo</a>
 		|
 		|
 		{% endif %}
 		{% endif %}
 		{% endwith %}
 		{% endwith %}
 		{% endif %}
 		{% endif %}
 		
 		
 		{% if tweet.actions.view_activity %}
 		{% if tweet.actions.view_activity %}
-		<a href="{{ url_for(tweet.actions.view_activity.route, **tweet.actions.view_activity.route_params) }}">activity</a>
+		<a href="{{ tweet.actions.view_activity.url }}">activity</a>
 		|
 		|
 		{% endif %}
 		{% endif %}
 		
 		
 		{% if tweet.actions.retweet %}
 		{% if tweet.actions.retweet %}
-		<a hx-post="{{ url_for(tweet.actions.retweet.route, **tweet.actions.retweet.route_params) }}">retweet</a>
+		<a hx-post="{{ tweet.actions.retweet.url }}">retweet</a>
 		|
 		|
 		{% endif %}
 		{% endif %}
 		
 		
 		{% if tweet.actions.bookmark %}
 		{% if tweet.actions.bookmark %}
-		<a hx-post="{{ url_for(tweet.actions.bookmark.route, **tweet.actions.bookmark.route_params) }}">bookmark</a>
+		<a hx-post="{{ tweet.actions.bookmark.url }}">bookmark</a>
 		{% if tweet.actions.delete_bookmark %}
 		{% if tweet.actions.delete_bookmark %}
 		[
 		[
-		<a hx-delete="{{ url_for(tweet.actions.delete_bookmark.route, **tweet.actions.delete_bookmark.route_params) }}">-</a>
+		<a hx-delete="{{ tweet.actions.delete_bookmark.url }}">-</a>
 		]
 		]
 		{% endif %}
 		{% endif %}
 		|
 		|

+ 105 - 1
hogumathi_app/templates/partial/user-card.html

@@ -1,3 +1,6 @@
+from dataclasses import dataclass
+from typing import List, Dict, Optional
+
 from hashlib import sha256
 from hashlib import sha256
 import os
 import os
 from pathlib import Path
 from pathlib import Path
@@ -49,7 +52,7 @@ def index ():
 
 
 @api.get('/img')
 @api.get('/img')
 def get_image ():
 def get_image ():
-	print('GET IMG')
+	print('GET IMG: FIXME this should be done with caching proxy')
 	url = request.args['url']
 	url = request.args['url']
 	url_hash = sha256(url.encode('utf-8')).hexdigest()
 	url_hash = sha256(url.encode('utf-8')).hexdigest()
 	path = f'.data/cache/media/{url_hash}'
 	path = f'.data/cache/media/{url_hash}'
@@ -180,6 +183,107 @@ def get_schedule_create_job_html ():
     
     
     return render_template_string(template, **view_model)
     return render_template_string(template, **view_model)
 
 
+@dataclass
+class FeedItemMedia:
+    url: str
+    mimetype: str
+
+@dataclass
+class FeedItem:
+    id: str
+    text: str
+    media: Optional[List[FeedItemMedia]] = None
+
+def ingest_feed_item (feed_item: FeedItem) -> FeedItem:
+    with sqlite3.connect('.data/ingest.db') as db:
+        with db.cursor() as cur:
+            #cur.row_factory = sqlite3.Row
+            
+            feed_item_table_exists = False # example in Hogumathi, twitter archive plugin I think
+            if not feed_item_table_exists:
+                cur.execute("""
+                    create table feed_item (
+                        id text,
+                        text text
+                    )
+                    
+                """)
+                cur.execute("""
+                    create table feed_item_media (
+                        feed_item_id integer,
+                        url text,
+                        mimetype text
+                    )
+                    
+                """)
+            sql = 'insert into feed_item (id, text) values (?, ?)'
+            params = [
+                feed_item.id,
+                feed_item.text
+                ]
+            
+            res = cur.execute(sql, params)
+            
+            if not res:
+                print('could not ingest feed_item')
+                return False
+            
+
+            
+            return feed_item
+
+
+def ingest_feed_item_media (feed_item: FeedItem) -> FeedItem:
+    print('ingest_feed_item_media')
+    
+    if not feed_item.media:
+        return feed_item
+    
+    with sqlite3.connect('.data/ingest.db') as db:
+        with db.cursor() as cur:
+            #cur.row_factory = sqlite3.Row
+            
+            for media_item in feed_item.media:
+                
+                # TODO import URL to files app and store that URL.
+                # may want to support several URLs, so that offline LANs work.
+                
+                sql = 'insert into feed_item_media (feed_item_id, url, mimetype) values (?, ?, ?)'
+                params = [
+                    feed_item.id,
+                    media_item.url,
+                    media_item.mimetype
+                    ]
+                res = cur.execute(sql, params)
+                
+                if not res:
+                    print('could not ingest feed_item_media')
+    
+    return feed_item
+    
+
+@api.post('/api/ingest/feed-item')
+def api_ingest_feed_item ():
+    """
+    Eventually other content sources with ingest here, and this will be the main DB.
+    Via inigest_feed_Item and ingest_feed_item_media.
+    
+    They could be worker tasks. Work when submitted directly from browser extension.
+    """
+    print('api_ingest_feed_item')
+    ingest_media = int(request.args.get('ingest_media', 1))
+    feed_item = request.args.get('feed_item') # FIXME might want to use post body/form
+    feed_item = from_dict(data_class=FeedItem, data=feed_item)
+    
+    fi_i_res = ingest_feed_item(feed_item)
+    
+    if ingest_media: # and fi_i_res(blocking=True, timeout=5):
+        fi_i_media_res = ingest_feed_item_media(feed_item)
+        
+        #fi_i_media_res(blocking=True)
+    
+    return 'ok'
+
 @api.get('/health')
 @api.get('/health')
 def get_health ():
 def get_health ():
     return 'ok'
     return 'ok'

+ 123 - 0
lib/email_source.py

@@ -0,0 +1,123 @@
+from dataclasses import dataclass, asdict
+from typing import Optional, List, Dict, Union
+from dacite import from_dict
+
+from email.utils import getaddresses, parseaddr
+
+import json
+import requests
+
+import messaging_support.types as hm_types
+
+
+class RmConversationContext:
+    
+    def add_routed_message (self, rm):
+        
+        references = eml.get('References')
+        
+        
+        return
+        
+class EmailConversationContext:
+    
+    def add_email (self, eml):
+        
+        # the first reference is the conversation starter
+        # the last is the message this is a reply to
+        # the middle are immediate replies, up to about 10 deep--
+        # ie. some of the chain may be missing; we can get the oldest message
+        #     and recursively rebuild upward
+        references = eml.get('References')
+        
+        
+        return
+
+
+def addr_to_ra (addr):
+    return {'name': addr[0], 'address': addr[1]}
+
+class EmailSource:
+    
+    def __init__ (self, host, user, password):
+        self.host = host
+        self.user = user
+        self.password = password
+    
+    def get_page (self, page=0, limit=10):
+        
+        resp = requests.get(f'http://localhost:5010/data/emails/imap?page={page}')
+        if resp.status_code != 200:
+            raise Exception(f'error: {resp.status_code}')
+        
+        resp_json = json.loads(resp.text)
+        
+        print(resp_json)
+        
+        # check_types=False because the string "None" creates a problem...
+        
+        root_key = resp_json['metaData']['root']
+        fields = resp_json['metaData']['fields']
+        rows = resp_json[ root_key ]
+        
+        data = []
+        for i, row in enumerate(rows):
+            msg_id = 'imap-' + str(i + (limit * page))
+            record = {'msg_id': msg_id, **{k: row[j] for j, k in enumerate(fields)}}
+            
+            record['conversation_id'] = msg_id
+            record['to'] = list(map(addr_to_ra, getaddresses([record['to']])))
+            record['from_'] = list(map(addr_to_ra, getaddresses([record['from']])))[0]
+            del record['from']
+            
+            rm = from_dict(data=record, data_class=hm_types.RoutedMessage)
+            
+            data.append(rm)
+        
+        routed_messages = data
+        
+        return routed_messages
+        
+    
+    def get_page2 (self, page=0, limit=10):
+        token = json.dumps({
+            'host': self.host,
+            'user': self.user,
+            'password': self.password
+        })
+        
+        headers = {
+            'Authorization': token
+        }
+        
+        resp = requests.get(f'http://localhost:5010/data/emails/imap2?page={page}', headers=headers)
+        if resp.status_code != 200:
+            raise Exception(f'error: {resp.status_code}')
+        
+        resp_json = json.loads(resp.text)
+        
+        routed_messages = list(map(lambda rm_dict: from_dict(data=rm_dict, data_class=hm_types.RoutedMessage), resp_json))
+        
+        return routed_messages
+
+    def send_message (self, to, subject, content):
+        
+        token = json.dumps({
+            'host': self.host,
+            'user': self.user,
+            'password': self.password
+        })
+        
+        headers = {
+            'Authorization': token
+        }
+        
+        form_data = {
+            'to': to,
+            'subject': subject,
+            'text': content
+        }
+        
+        resp = requests.post('http://localhost:5010/api/send-message', data=form_data, headers=headers)
+        
+        return resp

+ 7 - 0
lib/flask_bootstrap5/__init__.py

@@ -0,0 +1,7 @@
+from flask import Blueprint
+
+bootstrap5_bp = Blueprint('bootstrap5', __name__,
+    static_folder='static',
+    static_url_path='',
+    template_folder='templates',
+    url_prefix='/lib/bootstrap5')

+ 8 - 0
lib/flask_bootstrap5/templates/bootstrap5/partial/head.html

@@ -0,0 +1,8 @@
+
+{% with bootstrap5_module_path=bootstrap5_module_path or 'bootstrap5' %}
+<link href="{{ url_for(bootstrap5_module_path + '.static', filename='bootstrap.min.css') }}" rel="stylesheet" integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
+<script src="{{ url_for(bootstrap5_module_path + '.static', filename='bootstrap.bundle.min.js') }}" integrity="sha384-w76AqPfDkMBDXo30jS1Sgez6pr3x5MlQ1ZAGC+nuZB+EYdgRZgiwxhTBTkF7CXvN" crossorigin="anonymous"></script>
+
+<link rel="stylesheet" href="{{ url_for(bootstrap5_module_path + '.static', filename='bootstrap-icons.css') }}">
+
+{% endwith %}

+ 49 - 0
lib/mastodon_v2/api.py

@@ -0,0 +1,49 @@
+"""
+ViewModel oriented view of messages. Both DMs and Email should be supported from any provider,
+via the RoutedMessage abstraction.
+"""
+
+from dataclasses import dataclass
+from typing import Optional, List, Dict, Union
+
+Url = str
+Date = str
+
+MessageId = str
+
+@dataclass
+class RoutedAddress:
+    address: str
+    name: Optional[str]
+    avi_icon_url: Optional[Url] = None
+
+@dataclass 
+class MessageAttachment:
+    content_type: str
+    name: Optional[str] = None
+    size: Optional[int] = None
+    preview_url: Optional[Url] = None
+    url: Optional[Url] = None
+
+@dataclass
+class RoutedMessage:
+    to: List[RoutedAddress]
+    from_: RoutedAddress
+    date: Date
+    msg_id: MessageId
+    conversation_id: MessageId
+    in_reply_to_id: Optional[MessageId] = None
+    subject: Optional[str] = None
+    headers: Optional[Dict[str, str]] = None
+    cc: Optional[List[RoutedAddress]] = None
+    text: Optional[str] = None
+    html: Optional[str] = None
+    attachments: Optional[List[MessageAttachment]] = None # content type, name, size (fetch by index)
+    #references: Optional[List[Tuple[str,MessageId]]] = None # type, msg_id. type = forwarded, replied, quoted. Nostr "e" tag, basically.
+    #raw_bytes: Optional[bytes] = None
+
+@dataclass
+class Conversation:
+    id: str
+    participants: List[RoutedAddress]
+    messages: List[RoutedMessage] = None

+ 0 - 0
lib/twitter_v2/api.py