#26 Add pinboard upload feature

Merged
getty merged 2 commits from getty/gr/pinboard-upload into getty/master 4 years ago
7 changed files with 113 additions and 29 deletions
  1. 22 0
      lc/app.py
  2. 11 0
      lc/error.py
  3. 42 2
      lc/model.py
  4. 5 1
      lc/web.py
  5. 1 24
      scripts/populate.py
  6. 7 2
      templates/config.mustache
  7. 25 0
      templates/import.mustache

+ 22 - 0
lc/app.py

@@ -206,3 +206,25 @@ class GetTaggedLinks(Endpoint):
                 user=self.user,
             ),
         )
+
+
+@endpoint("/u/<string:user>/import")
+class PinboardImport(Endpoint):
+    def html(self, user: str):
+        u = self.require_authentication(user)
+        return render(
+            "main",
+            v.Page(
+                title=f"import pinboard data", content=render("import"), user=self.user,
+            ),
+        )
+
+    def api_post(self, user: str):
+        u = self.require_authentication(user)
+        if "file" not in flask.request.files:
+            raise e.BadFileUpload("could not find attached file")
+        file = flask.request.files["file"]
+        if file.filename == "":
+            raise e.BadFileUpload("no file selected")
+        u.import_pinboard_data(file.stream)
+        return self.api_ok(u.base_url())

+ 11 - 0
lc/error.py

@@ -149,3 +149,14 @@ class BadAddLink(LCException):
 
     def http_code(self) -> int:
         return 400
+
+
+@dataclasss
+class BadFileUpload(LCException):
+    message: str
+
+    def __str__(self):
+        return f"Problem with uploaded file: {self.message}"
+
+    def http_code(self) -> int:
+        return 400

+ 42 - 2
lc/model.py

@@ -1,5 +1,6 @@
 from dataclasses import dataclass
 import datetime
+import json
 from passlib.apps import custom_app_context as pwd
 import peewee
 import playhouse.shortcuts
@@ -110,6 +111,43 @@ class User(Model):
             admin_pane = v.AdminPane(invites=user_invites)
         return v.Config(username=self.name, admin_pane=admin_pane,)
 
+    def import_pinboard_data(self, stream):
+        try:
+            links = json.load(stream)
+        except json.decoder.JSONDecodeError as exn:
+            raise e.BadFileUpload("could not parse file as JSON")
+
+        if not isinstance(links, list):
+            raise e.BadFileUpload(f"expected a list")
+
+        # create and (for this session) cache the tags
+        tags = {}
+        for l in links:
+            if "tags" not in l:
+                raise e.BadFileUpload("missing key {exn.args[0]}")
+            for t in l["tags"].split():
+                if t in tags:
+                    continue
+
+                tags[t] = Tag.get_or_create_tag(self, t)
+
+        with c.db.atomic():
+            for l in links:
+                try:
+                    time = datetime.datetime.strptime(l["time"], "%Y-%m-%dT%H:%M:%SZ")
+                    ln = Link.create(
+                        url=l["href"],
+                        name=l["description"],
+                        description=l["extended"],
+                        private=l["shared"] == "no",
+                        created=time,
+                        user=self,
+                    )
+                except KeyError as exn:
+                    raise e.BadFileUpload(f"missing key {exn.args[0]}")
+                for t in l["tags"].split():
+                    HasTag.get_or_create(link=ln, tag=tags[t])
+
 
 class Link(Model):
     """
@@ -145,8 +183,7 @@ class Link(Model):
         )
         for tag_name in link.tags:
             tag = Tag.get_or_create_tag(user, tag_name)
-            for t in tag.get_family():
-                HasTag.get_or_create(link=l, tag=t)
+            HasTag.get_or_create(link=l, tag=tag)
         return l
 
     def update_from_request(self, user: User, link: r.Link):
@@ -266,6 +303,9 @@ class HasTag(Model):
         if res is None:
             res = HasTag.create(link=link, tag=tag)
 
+        if tag.parent:
+            HasTag.get_or_create(link, tag.parent)
+
         return res
 
 

+ 5 - 1
lc/web.py

@@ -62,8 +62,12 @@ class Endpoint:
             # in error handlers, so we should be resilient to that!
             return None
 
+    SHOULD_REDIRECT = set(("application/x-www-form-urlencoded", "multipart/form-data",))
+
     def api_ok(self, redirect: str, data: dict = {"status": "ok"}) -> ApiOK:
-        if flask.request.content_type == "application/x-www-form-urlencoded":
+        content_type = flask.request.content_type or ""
+        content_type = content_type.split(";")[0]
+        if content_type in Endpoint.SHOULD_REDIRECT:
             raise e.LCRedirect(redirect)
         else:
             return ApiOK(response=data)

+ 1 - 24
scripts/populate.py

@@ -23,30 +23,7 @@ def main():
     c.log(f"created user {u.name}")
 
     with open("scripts/aisamanra.json") as f:
-        links = json.load(f)
-
-    tags = {}
-    for l in links:
-        for t in l["tags"].split():
-            if t in tags:
-                continue
-
-            tags[t] = m.Tag.get_or_create_tag(u, t)
-
-    with c.db.atomic():
-        for l in links:
-            time = datetime.datetime.strptime(l["time"], "%Y-%m-%dT%H:%M:%SZ")
-            ln = m.Link.create(
-                url=l["href"],
-                name=l["description"],
-                description=l["extended"],
-                private=l["shared"] == "no",
-                created=time,
-                user=u,
-            )
-            for t in l["tags"].split():
-                m.HasTag.create(link=ln, tag=tags[t])
-            c.log(f"created link {ln.url}")
+        u.import_pinboard_data(f)
 
 
 if __name__ == "__main__":

+ 7 - 2
templates/config.mustache

@@ -22,8 +22,13 @@
 </div>
 <div class="config-pane">
   <div class="config">
-  <p>Drag the following link to your bookmark bar to create a bookmarklet:</p>
-  <p><a href="{{bookmarklet_link}}">Add Lament</a></p>
+    <p>Drag the following link to your bookmark bar to create a bookmarklet:</p>
+    <p><a href="{{bookmarklet_link}}">Add Lament</a></p>
+  </div>
+</div>
+<div class="config-pane">
+  <div class="config">
+    <p><a href="/u/{{username}}/import">Import my bookmarks from Pinboard</a></p>
   </div>
 </div>
 {{#admin_pane}}

+ 25 - 0
templates/import.mustache

@@ -0,0 +1,25 @@
+<div class="config-pane">
+  <div class="config">
+    <p>
+      Here you can import your Pinboard bookmarks. Export your
+      Pinboard data as JSON and then upload it below.
+    </p>
+    <p>
+      Note that tags in Lament Configuration do not work the same way
+      as tags in Pinboard. In particular, Lament Configuration understands
+      the forward slash (<code>/</code>) as a marker of hierarchical
+      tags: tagging a link with <code>#food/bread</code> will automatically tag it with
+      <code>#food</code> as well.
+    </p>
+  </div>
+</div>
+<div class="config-pane">
+  <div class="config">
+    <form name="import" method="POST" enctype="multipart/form-data">
+      <div>
+        <input name="file" type="file" />
+        <input type="submit" value="Import" />
+      </div>
+    </form>
+  </div>
+</div>