tupton / alfred-chrome-history

Search your Google Chrome history in Alfred
198 stars 17 forks source link

Share updated script for python3 #33

Closed seonWKim closed 2 months ago

seonWKim commented 2 months ago

Current version supports python below version 3. I'd like to share the updated version google_chrome_history_workflow_python3.zip

What has changed

I don't know much about alfred, and changing alfred.py doesn't seem to be the right way(though it works with python3)

chrome.py

#!/usr/bin/env python3

"""
Get relevant history from the Google Chrome history database based on the given query and build
Alfred items based on the results.

Usage:
    chrome.py [--no-favicons | --favicons] PROFILE QUERY
    chrome.py (-h | --help)
    chrome.py --version

The path to the Chrome user profile to get the history database from is given in PROFILE. The query
to search for is given in QUERY. The output is formatted as the Alfred script filter XML output
format.

    PROFILE  The path to the Chrome profile whose history database should be searched
    QUERY    The query to search the history database with

Options:
    --no-favicons  Do not return Alfred XML results with favicons [default: false]
    --favicons     Include favicons in the Alfred XML results [default: true]
"""

import alfred
import sqlite3
import shutil
import os
import sys
import time
import datetime
from docopt import docopt

__version__ = '0.7.0'

CACHE_EXPIRY = 60
HISTORY_DB = 'History'
FAVICONS_DB = 'Favicons'
FAVICONS_CACHE = 'Favicons-Cache'

FAVICON_JOIN = """
    LEFT OUTER JOIN icon_mapping ON icon_mapping.page_url = urls.url,
                    favicon_bitmaps ON favicon_bitmaps.id =
                        (SELECT id FROM favicon_bitmaps
                            WHERE favicon_bitmaps.icon_id = icon_mapping.icon_id
                            ORDER BY width DESC LIMIT 1)
"""
FAVICON_SELECT = """
    , favicon_bitmaps.image_data, favicon_bitmaps.last_updated
"""

HISTORY_QUERY = """
SELECT urls.id, urls.title, urls.url {favicon_select}
    FROM urls
    {favicon_join}
    WHERE (urls.title LIKE ? OR urls.url LIKE ?)
    ORDER BY visit_count DESC, typed_count DESC, last_visit_time DESC
"""

# UNIX_EPOCH = datetime.datetime.fromtimestamp(0, datetime.timezone.utc)
# WINDOWS_EPOCH = datetime.datetime(1601, 1, 1)
# SECONDS_BETWEEN_UNIX_AND_WINDOWS_EPOCH = (UNIX_EPOCH - WINDOWS_EPOCH).total_seconds()
UNIX_EPOCH = datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)
WINDOWS_EPOCH = datetime.datetime(1601, 1, 1, tzinfo=datetime.timezone.utc)
SECONDS_BETWEEN_UNIX_AND_WINDOWS_EPOCH = (UNIX_EPOCH - WINDOWS_EPOCH).total_seconds()
MICROSECS_PER_SEC = 10 ** -6

class ErrorItem(alfred.Item):
    def __init__(self, error):
        super().__init__({u'valid': u'NO', u'autocomplete': error}, error, u'Check the workflow log for more information.')

def alfred_error(error):
    alfred.write(alfred.xml([ErrorItem(error)]))

def copy_db(name, profile):
    cache = os.path.join(alfred.work(True), name)
    if os.path.isfile(cache) and time.time() - os.path.getmtime(cache) < CACHE_EXPIRY:
        return cache

    db_file = os.path.join(os.path.expanduser(profile), name)
    try:
        shutil.copy(db_file, cache)
    except:
        raise IOError('Unable to copy Google Chrome history database from {}'.format(db_file))

    return cache

def history_db(profile, favicons=True):
    history = copy_db(HISTORY_DB, profile)
    db = sqlite3.connect(history)
    if favicons:
        favicons = copy_db(FAVICONS_DB, profile)
        db.cursor().execute('ATTACH DATABASE ? AS favicons', (favicons,)).close()
    return db

def cache_favicon(image_data, uid, last_updated):
    cache_dir = os.path.join(alfred.work(True), FAVICONS_CACHE)
    if not os.path.isdir(cache_dir):
        os.makedirs(cache_dir)
    icon_file = os.path.join(cache_dir, str(uid))
    # Convert last_updated to timestamp for comparison
    last_updated_ts = last_updated.timestamp()
    if not os.path.isfile(icon_file) or last_updated_ts > os.path.getmtime(icon_file):
        with open(icon_file, 'wb') as f:  # Ensure binary write mode
            f.write(image_data)
        # Update the file's modification time to last_updated_ts
        os.utime(icon_file, (time.time(), last_updated_ts))

    return (icon_file, {'type': 'png'})

def convert_chrometime(chrometime):
    return datetime.datetime.fromtimestamp((chrometime / 10**6) - SECONDS_BETWEEN_UNIX_AND_WINDOWS_EPOCH, tz=datetime.timezone.utc)

def history_results(db, query, favicons=True):
    q = u'%{}%'.format(query)
    if favicons:
        favicon_select = FAVICON_SELECT
        favicon_join = FAVICON_JOIN
    else:
        favicon_select = ''
        favicon_join = ''
    for row in db.execute(HISTORY_QUERY.format(favicon_select=favicon_select, favicon_join=favicon_join), (q, q)):
        if favicons:
            (uid, title, url, image_data, image_last_updated) = row
            icon = cache_favicon(image_data, uid, convert_chrometime(image_last_updated)) if image_data and image_last_updated else None
        else:
            (uid, title, url) = row
            icon = None

        yield alfred.Item({u'uid': alfred.uid(uid), u'arg': url, u'autocomplete': url}, title or url, url, icon=icon)

if __name__ == '__main__':
    arguments = docopt(__doc__, version=__version__)
    favicons = arguments.get('--no-favicons') is False

    profile = arguments.get('PROFILE')
    query = arguments.get('QUERY')

    try:
        db = history_db(profile, favicons=favicons)
    except IOError as e:
        alfred_error(e)
        sys.exit(-1)

    alfred.write(alfred.xml(list(history_results(db, query, favicons=favicons))))

alfred.py

import itertools
import os
import plistlib
import unicodedata
import sys

from xml.etree.ElementTree import Element, SubElement, tostring

UNESCAPE_CHARACTERS = " ;()"

_MAX_RESULTS_DEFAULT = 9

# Updated to use plistlib.load for Python 3 compatibility
with open('info.plist', 'rb') as fp:
    preferences = plistlib.load(fp)
bundleid = preferences['bundleid']

class Item:
    @classmethod
    def unicode(cls, value):
        try:
            items = iter(value.items())
        except AttributeError:
            # Directly return the value if it's not a dict
            return str(value)
        else:
            # Use str instead of unicode for Python 3 compatibility
            return dict(map(cls.unicode, item) for item in items)

    def __init__(self, attributes, title, subtitle, icon=None):
        self.attributes = attributes
        self.title = title
        self.subtitle = subtitle
        self.icon = icon

    def __str__(self):
        return tostring(self.xml()).decode('utf-8')

    def xml(self):
        item = Element('item', self.unicode(self.attributes))
        for attribute in ('title', 'subtitle', 'icon'):
            value = getattr(self, attribute)
            if value is None:
                continue
            if len(value) == 2 and isinstance(value[1], dict):
                (value, attributes) = value
            else:
                attributes = {}
            SubElement(item, attribute, self.unicode(attributes)).text = self.unicode(value)
        return item

def args(characters=None):
    return tuple(unescape(decode(arg), characters) for arg in sys.argv[1:])

def config():
    return _create('config')

def decode(s):
    # Removed .decode('utf-8') as strings are already Unicode in Python 3
    return unicodedata.normalize('NFD', s)

def env(key):
    return os.environ[f'alfred_{key}']

def uid(uid):
    return '-'.join(map(str, (bundleid, uid)))

def unescape(query, characters=None):
    for character in (UNESCAPE_CHARACTERS if (characters is None) else characters):
        query = query.replace(f'\\{character}', character)
    return query

def work(volatile):
    path = {
        True: env('workflow_cache'),
        False: env('workflow_data')
    }[bool(volatile)]
    return _create(path)

def write(text):
    if isinstance(text, bytes):
        text = text.decode('utf-8')
    sys.stdout.write(text)

def xml(items, maxresults=_MAX_RESULTS_DEFAULT):
    root = Element('items')
    for item in itertools.islice(items, maxresults):
        root.append(item.xml())
    return tostring(root, encoding='utf-8')

def _create(path):
    if not os.path.isdir(path):
        os.mkdir(path)
    if not os.access(path, os.W_OK):
        raise IOError(f'No write access: {path}')
    return path