root/software/bookmark_library/lib/bookmark.py @ 4c6410dc
| 137b22ce | dsorber | import datetime
|
||
| 655be0cd | dsorber | import re
|
||
| 4c6410dc | dsorber | from urllib.error import HTTPError, URLError
|
||
| 655be0cd | dsorber | from urllib.request import urlopen
|
||
| 137b22ce | dsorber | import dateutil.parser
|
||
| aa15db9e | dsorber | import sqlite3
|
||
| 7218b9c1 | dsorber | from tag import Tag
|
||
| aa15db9e | dsorber | |||
DB_PATH = 'bookmarks.db'
|
||||
| 655be0cd | dsorber | SQL_SELECT_BOOKMARK = "SELECT * FROM bookmarks WHERE id=?"
|
||
| 137b22ce | dsorber | SQL_INSERT_BOOKMARK = "INSERT INTO bookmarks VALUES (?,?,?,?,?,?,?,?,?)"
|
||
| aa15db9e | dsorber | SQL_UPDATE_BOOKMARK = "UPDATE bookmarks SET {:s} WHERE id=?"
|
||
| fd2c83de | dsorber | SQL_SELECT_CAT_TAGS = "SELECT tag_id FROM category_tags WHERE bookmark_id=?"
|
||
SQL_SELECT_DESC_TAGS = "SELECT tag_id FROM description_tags WHERE bookmark_id=?"
|
||||
SQL_DELETE_CAT_TAGS = "DELETE FROM category_tags WHERE bookmark_id=?"
|
||||
SQL_DELETE_DESC_TAGS = "DELETE FROM description_tags WHERE bookmark_id=?"
|
||||
SQL_INSERT_CAT_TAGS = "INSERT INTO category_tags VALUES (?, ?)"
|
||||
SQL_INSERT_DESC_TAGS = "INSERT INTO description_tags VALUES (?, ?)"
|
||||
| aa15db9e | dsorber | |||
| 655be0cd | dsorber | HTTP_OKAY = 200
|
||
class BookmarkNotFoundError(Exception):
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
def __str__(self, value):
|
||||
return repr(self.value)
|
||||
| aa15db9e | dsorber | class DBConnectionError(Exception):
|
||
def __init__(self, value):
|
||||
self.value = value
|
||||
def __str__(self, value):
|
||||
return repr(self.value)
|
||||
| 137b22ce | dsorber | def _format_date(date):
|
||
if date is not None:
|
||||
return date.isoformat()
|
||||
else:
|
||||
return date
|
||||
| aa15db9e | dsorber | class Bookmark(object):
|
||
def __init__(self, db_conn, id=None, url=None):
|
||||
| 7056140d | dsorber | # Make sure the DB connection is valid before doing anything else
|
||
| aa15db9e | dsorber | if not db_conn:
|
||
raise DBConnectionError('Invalid database connection object!')
|
||||
# Setup internal parameters
|
||||
self._db_conn = db_conn
|
||||
| 655be0cd | dsorber | self._content = None
|
||
| aa15db9e | dsorber | |||
# Setup defaut object data attributes
|
||||
self.id = id
|
||||
self.url = url
|
||||
self.title = ''
|
||||
self.description = ''
|
||||
self.times_visited = 0
|
||||
| 137b22ce | dsorber | self.last_visited = None
|
||
self.last_reachable = None
|
||||
self.date_added = None
|
||||
| aa15db9e | dsorber | self.deleted = False
|
||
| 7056140d | dsorber | self.category_tags = set()
|
||
self.description_tags = set()
|
||||
| aa15db9e | dsorber | |||
| 7056140d | dsorber | # Retrieve an existing bookmark if an ID is passed in
|
||
| fd2c83de | dsorber | if self.id is not None:
|
||
| 655be0cd | dsorber | db = self._db_conn.cursor()
|
||
db.execute(SQL_SELECT_BOOKMARK, (self.id,))
|
||||
row = db.fetchone()
|
||||
# Check to see if a record was found
|
||||
if not row:
|
||||
raise BookmarkNotFoundError(self.id)
|
||||
# Assign data attributes
|
||||
self.url = row[1]
|
||||
self.title = row[2]
|
||||
self.description = row[3]
|
||||
self.times_visited = int(row[4])
|
||||
| 137b22ce | dsorber | if row[5]:
|
||
self.last_visited = dateutil.parser.parse(row[5])
|
||||
if row[6]:
|
||||
self.last_reachable = dateutil.parser.parse(row[6])
|
||||
self.date_added = dateutil.parser.parse(row[7])
|
||||
if int(row[8]) == 0:
|
||||
| 655be0cd | dsorber | self.deleted = False
|
||
else:
|
||||
self.deleted = True
|
||||
| 7218b9c1 | dsorber | |||
# Grab category tags
|
||||
for row in db.execute(SQL_SELECT_CAT_TAGS, (self.id,)):
|
||||
| fd2c83de | dsorber | self.category_tags.add(Tag(int(row[0])))
|
||
# Grab description tags
|
||||
| 7218b9c1 | dsorber | for row in db.execute(SQL_SELECT_DESC_TAGS, (self.id,)):
|
||
| fd2c83de | dsorber | self.description_tags.add(Tag(int(row[0])))
|
||
| 137b22ce | dsorber | # Store hash of tag sets for dirty comparison
|
||
| fd2c83de | dsorber | self._cat_tags_hash = hash(frozenset(self.category_tags))
|
||
self._desc_tags_hash = hash(frozenset(self.description_tags))
|
||||
| 655be0cd | dsorber | |||
# This dict MUST appear *after* the data attributes, it is used to
|
||||
| aa15db9e | dsorber | # record which data attributes are dirty
|
||
self._dirty = {'url': False,
|
||||
'title': False,
|
||||
'description': False,
|
||||
'times_visited': False,
|
||||
'last_visited': False,
|
||||
'last_reachable': False,
|
||||
| 7218b9c1 | dsorber | 'deleted': False}
|
||
| aa15db9e | dsorber | |||
def __del__(self):
|
||||
| 655be0cd | dsorber | """ Automatically save any changes to a record before the object is
|
||
destroyed.
|
||||
"""
|
||||
self.save()
|
||||
def save(self):
|
||||
""" Save any changes to a bookmark record if it is dirty. """
|
||||
| aa15db9e | dsorber | # Check if the record is dirty
|
||
if self.record_dirty:
|
||||
if self.id:
|
||||
| 655be0cd | dsorber | # Update existing bookmark record
|
||
self._db_update()
|
||||
| aa15db9e | dsorber | else:
|
||
| 655be0cd | dsorber | # Insert new bookmark record
|
||
| aa15db9e | dsorber | self._db_insert()
|
||
def __setattr__(self, name, value):
|
||||
""" Customize setting data attributes so we can tell which ones
|
||||
are dirty.
|
||||
"""
|
||||
| 137b22ce | dsorber | # The date_added attribute is immutable once it has been set
|
||
if hasattr(self, 'date_added') and name == 'date_added':
|
||||
if getattr(self, name) is not None:
|
||||
return
|
||||
| aa15db9e | dsorber | |||
# Check if the attr is a data attribute then check if the value has
|
||||
# changed. If so, mark the attribute as dirty
|
||||
if hasattr(self, '_dirty'):
|
||||
if name in self._dirty and getattr(self, name) != value:
|
||||
self._dirty[name] = True
|
||||
# Set the attribute value using the super class's __setattr__ to avoid
|
||||
# infinite recursion (recursion joke here)
|
||||
super(Bookmark, self).__setattr__(name, value)
|
||||
def _db_insert(self):
|
||||
| 655be0cd | dsorber | """ Insert a new bookmark record into the database. """
|
||
| 137b22ce | dsorber | self.date_added = datetime.datetime.now()
|
||
| aa15db9e | dsorber | db = self._db_conn.cursor()
|
||
| fd2c83de | dsorber | values = (None, self.url, self.title, self.description,
|
||
| 137b22ce | dsorber | self.times_visited, _format_date(self.last_visited),
|
||
_format_date(self.last_reachable),
|
||||
_format_date(self.date_added),
|
||||
| aa15db9e | dsorber | self.deleted and '1' or '0')
|
||
db.execute(SQL_INSERT_BOOKMARK, values)
|
||||
| fd2c83de | dsorber | |||
# Set bookmark ID after insert
|
||||
self.id = db.lastrowid
|
||||
# Insert any category tags
|
||||
for tag in self.category_tags:
|
||||
tag.save()
|
||||
db.execute(SQL_INSERT_CAT_TAGS, (tag.id, self.id))
|
||||
# Insert any description tags
|
||||
for tag in self.description_tags:
|
||||
tag.save()
|
||||
db.execute(SQL_INSERT_DESC_TAGS, (tag.id, self.id))
|
||||
| aa15db9e | dsorber | self._db_conn.commit()
|
||
# Reset dirty flags
|
||||
for key in self._dirty.keys():
|
||||
self._dirty[key] = False
|
||||
def _db_update(self):
|
||||
| 655be0cd | dsorber | """ Update an exisitng bookmark record in the database. """
|
||
| aa15db9e | dsorber | values = []
|
||
sets = []
|
||||
# Find dirty data attributes for updating
|
||||
for key in self._dirty.keys():
|
||||
| 655be0cd | dsorber | if self._dirty[key] == True:
|
||
| aa15db9e | dsorber | sets.append('{:s}=?'.format(key))
|
||
| 137b22ce | dsorber | if key in ['last_reachable', 'last_visited']:
|
||
# Datetime objects need to be formated before update
|
||||
values.append(_format_date(getattr(self, key)))
|
||||
else:
|
||||
values.append(getattr(self, key))
|
||||
| 4c6410dc | dsorber | |||
db = self._db_conn.cursor()
|
||||
| fd2c83de | dsorber | if values and sets:
|
||
# Add the record id as the last value
|
||||
values.append(self.id)
|
||||
# Build full SQL statement then execute it
|
||||
sql = SQL_UPDATE_BOOKMARK.format(','.join(sets))
|
||||
db.execute(sql, values)
|
||||
# Reset dirty flags
|
||||
for key in self._dirty.keys():
|
||||
self._dirty[key] = False
|
||||
| aa15db9e | dsorber | |||
| fd2c83de | dsorber | # Inner function FTW
|
||
| 4c6410dc | dsorber | def update_tag_lists(delete_sql, insert_sql, tags):
|
||
| fd2c83de | dsorber | db.execute(delete_sql, (self.id,))
|
||
tag_updates = []
|
||||
for tag in tags:
|
||||
tag.save()
|
||||
tag_updates.append((tag.id, self.id))
|
||||
db.executemany(insert_sql, tag_updates)
|
||||
# Update tag lists if they are dirty
|
||||
if self.category_tags_dirty:
|
||||
update_tag_lists(SQL_DELETE_CAT_TAGS, SQL_INSERT_CAT_TAGS,
|
||||
self.category_tags)
|
||||
if self.description_tags_dirty:
|
||||
update_tag_lists(SQL_DELETE_DESC_TAGS, SQL_INSERT_DESC_TAGS,
|
||||
self.description_tags)
|
||||
| 137b22ce | dsorber | |||
self._db_conn.commit()
|
||||
| fd2c83de | dsorber | |||
@property
|
||||
def category_tags_dirty(self):
|
||||
return self._cat_tags_hash != hash(frozenset(self.category_tags))
|
||||
| aa15db9e | dsorber | |||
| fd2c83de | dsorber | @property
|
||
def description_tags_dirty(self):
|
||||
return self._desc_tags_hash != hash(frozenset(self.description_tags))
|
||||
| aa15db9e | dsorber | |||
@property
|
||||
def record_dirty(self):
|
||||
""" Check if any data attributes are dirty, which indicates the record
|
||||
is dirty.
|
||||
"""
|
||||
| fd2c83de | dsorber | dirty = [val for key, val in self._dirty.items()]
|
||
dirty.append(self.category_tags_dirty)
|
||||
dirty.append(self.description_tags_dirty)
|
||||
return any(dirty)
|
||||
| 655be0cd | dsorber | |||
def _get_content(self):
|
||||
""" Read the page and get its raw page HTML content. """
|
||||
if self.url:
|
||||
| 4c6410dc | dsorber | try:
|
||
response = urlopen(self.url)
|
||||
if response.getcode() == HTTP_OKAY:
|
||||
content = response.read()
|
||||
try:
|
||||
self._content = content.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
self._content = content.decode('ISO-8859-1')
|
||||
except (HTTPError, URLError):
|
||||
return ''
|
||||
| 655be0cd | dsorber | |||
def suggest_title(self):
|
||||
""" Return the contents of the page's <title> tag as a title suggestion
|
||||
"""
|
||||
if self.url and not self._content:
|
||||
self._get_content()
|
||||
try:
|
||||
title = re.search('<title>(.+)</title>', self._content).group(1)
|
||||
| 4c6410dc | dsorber | except (IndexError, AttributeError, TypeError):
|
||
| 655be0cd | dsorber | title = None
|
||
return title
|
||||
def is_reachable(self):
|
||||
""" Check if the page is reachable. """
|
||||
if self.url:
|
||||
| 4c6410dc | dsorber | try:
|
||
response = urlopen(self.url)
|
||||
if response.getcode() == HTTP_OKAY:
|
||||
self.last_reachable = datetime.datetime.now()
|
||||
return True
|
||||
except (HTTPError, URLError):
|
||||
return False
|
||||
| 655be0cd | dsorber | return False
|
||
| 137b22ce | dsorber | def visit(self):
|
||
""" Increment the visit count and update the last visited datestamp """
|
||||
self.times_visited += 1
|
||||
self.last_visited = datetime.datetime.now()
|