diff --git a/qutebrowser/completion/models/sqlcategory.py b/qutebrowser/completion/models/histcategory.py
similarity index 54%
rename from qutebrowser/completion/models/sqlcategory.py
rename to qutebrowser/completion/models/histcategory.py
index 2664edbfe..1899c2805 100644
--- a/qutebrowser/completion/models/sqlcategory.py
+++ b/qutebrowser/completion/models/histcategory.py
@@ -17,7 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see .
-"""A completion model backed by SQL tables."""
+"""A completion category that queries the SQL History store."""
import re
@@ -26,50 +26,50 @@ from PyQt5.QtSql import QSqlQueryModel
from qutebrowser.misc import sql
from qutebrowser.utils import debug
from qutebrowser.commands import cmdexc
+from qutebrowser.config import config
-class SqlCategory(QSqlQueryModel):
+class HistoryCategory(QSqlQueryModel):
- """Wraps a SqlQuery for use as a completion category."""
+ """A completion category that queries the SQL History store."""
- def __init__(self, name, *, title=None, filter_fields, sort_by=None,
- sort_order=None, select='*',
- delete_func=None, parent=None):
- """Create a new completion category backed by a sql table.
-
- Args:
- name: Name of the table in the database.
- title: Title of category, defaults to table name.
- filter_fields: Names of fields to apply filter pattern to.
- select: A custom result column expression for the select statement.
- sort_by: The name of the field to sort by, or None for no sorting.
- sort_order: Either 'asc' or 'desc', if sort_by is non-None
- delete_func: Callback to delete a selected item.
- """
+ def __init__(self, *, delete_func=None, parent=None):
+ """Create a new History completion category."""
super().__init__(parent=parent)
- self.name = title or name
+ self.name = "History"
- querystr = 'select {} from {} where ('.format(select, name)
- # the incoming pattern will have literal % and _ escaped with '\'
- # we need to tell sql to treat '\' as an escape character
- querystr += ' or '.join("{} like :pattern escape '\\'".format(f)
- for f in filter_fields)
- querystr += ')'
+ # replace ' to avoid breaking the query
+ timefmt = "strftime('{}', last_atime, 'unixepoch')".format(
+ config.get('completion', 'timestamp-format').replace("'", "`"))
- if sort_by:
- assert sort_order in ['asc', 'desc'], sort_order
- querystr += ' order by {} {}'.format(sort_by, sort_order)
- else:
- assert sort_order is None, sort_order
+ self._query = sql.Query(' '.join([
+ "SELECT url, title, {}".format(timefmt),
+ "FROM CompletionHistory",
+ # the incoming pattern will have literal % and _ escaped with '\'
+ # we need to tell sql to treat '\' as an escape character
+ "WHERE (url LIKE :pat escape '\\' or title LIKE :pat escape '\\')",
+ self._atime_expr(),
+ "ORDER BY last_atime DESC",
+ ]), forward_only=False)
- self._query = sql.Query(querystr, forward_only=False)
-
- # map filter_fields to indices
- col_query = sql.Query('SELECT * FROM {} LIMIT 1'.format(name))
- rec = col_query.run().record()
- self.columns_to_filter = [rec.indexOf(n) for n in filter_fields]
+ # advertise that this model filters by URL and title
+ self.columns_to_filter = [0, 1]
self.delete_func = delete_func
+ def _atime_expr(self):
+ """If max_items is set, return an expression to limit the query."""
+ max_items = config.get('completion', 'web-history-max-items')
+ if max_items < 0:
+ return ''
+
+ min_atime = sql.Query(' '.join([
+ 'SELECT min(last_atime) FROM',
+ '(SELECT last_atime FROM CompletionHistory',
+ 'ORDER BY last_atime DESC LIMIT :limit)',
+ ])).run(limit=max_items).value()
+
+ return "AND last_atime >= {}".format(min_atime)
+
def set_pattern(self, pattern):
"""Set the pattern used to filter results.
@@ -83,7 +83,7 @@ class SqlCategory(QSqlQueryModel):
pattern = re.sub(r' +', '%', pattern)
pattern = '%{}%'.format(pattern)
with debug.log_time('sql', 'Running completion query'):
- self._query.run(pattern=pattern)
+ self._query.run(pat=pattern)
self.setQuery(self._query)
def delete_cur_item(self, index):
diff --git a/qutebrowser/completion/models/urlmodel.py b/qutebrowser/completion/models/urlmodel.py
index 4f5fdeae9..0c5fbeacc 100644
--- a/qutebrowser/completion/models/urlmodel.py
+++ b/qutebrowser/completion/models/urlmodel.py
@@ -20,8 +20,7 @@
"""Function to return the url completion model for the `open` command."""
from qutebrowser.completion.models import (completionmodel, listcategory,
- sqlcategory)
-from qutebrowser.config import config
+ histcategory)
from qutebrowser.utils import log, objreg
@@ -66,14 +65,6 @@ def url():
model.add_category(listcategory.ListCategory(
'Bookmarks', bookmarks, delete_func=_delete_bookmark))
- # replace 's to avoid breaking the query
- timefmt = config.get('completion', 'timestamp-format').replace("'", "`")
- select_time = "strftime('{}', last_atime, 'unixepoch')".format(timefmt)
- hist_cat = sqlcategory.SqlCategory(
- 'CompletionHistory', title='History',
- sort_order='desc', sort_by='last_atime',
- filter_fields=['url', 'title'],
- select='url, title, {}'.format(select_time),
- delete_func=_delete_history)
+ hist_cat = histcategory.HistoryCategory(delete_func=_delete_history)
model.add_category(hist_cat)
return model
diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py
index 1245429c2..8bae2bae0 100644
--- a/qutebrowser/config/config.py
+++ b/qutebrowser/config/config.py
@@ -382,7 +382,6 @@ class ConfigManager(QObject):
('storage', 'offline-storage-default-quota'),
('storage', 'offline-web-application-cache-quota'),
('content', 'css-regions'),
- ('completion', 'web-history-max-items'),
]
CHANGED_OPTIONS = {
('content', 'cookies-accept'):
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
index 6af04d9f4..23d3efb67 100644
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -502,6 +502,11 @@ def data(readonly=False):
"How many commands to save in the command history.\n\n"
"0: no history / -1: unlimited"),
+ ('web-history-max-items',
+ SettingValue(typ.Int(minval=-1), '-1'),
+ "How many URLs to show in the web history.\n\n"
+ "0: no history / -1: unlimited"),
+
('quick-complete',
SettingValue(typ.Bool(), 'true'),
"Whether to move on to the next part when there's only one "
diff --git a/tests/unit/completion/test_histcategory.py b/tests/unit/completion/test_histcategory.py
new file mode 100644
index 000000000..f285c1dcc
--- /dev/null
+++ b/tests/unit/completion/test_histcategory.py
@@ -0,0 +1,150 @@
+# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
+
+# Copyright 2016-2017 Ryan Roden-Corrent (rcorre)
+#
+# This file is part of qutebrowser.
+#
+# qutebrowser is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# qutebrowser is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with qutebrowser. If not, see .
+
+"""Test the web history completion category."""
+
+import unittest.mock
+import time
+
+import pytest
+
+from qutebrowser.misc import sql
+from qutebrowser.completion.models import histcategory
+from qutebrowser.commands import cmdexc
+
+
+@pytest.fixture
+def hist(init_sql, config_stub):
+ config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d',
+ 'web-history-max-items': -1}
+ return sql.SqlTable('CompletionHistory', ['url', 'title', 'last_atime'])
+
+
+@pytest.mark.parametrize('pattern, before, after', [
+ ('foo',
+ [('foo', ''), ('bar', ''), ('aafobbb', '')],
+ [('foo',)]),
+
+ ('FOO',
+ [('foo', ''), ('bar', ''), ('aafobbb', '')],
+ [('foo',)]),
+
+ ('foo',
+ [('FOO', ''), ('BAR', ''), ('AAFOBBB', '')],
+ [('FOO',)]),
+
+ ('foo',
+ [('baz', 'bar'), ('foo', ''), ('bar', 'foo')],
+ [('foo', ''), ('bar', 'foo')]),
+
+ ('foo',
+ [('fooa', ''), ('foob', ''), ('fooc', '')],
+ [('fooa', ''), ('foob', ''), ('fooc', '')]),
+
+ ('foo',
+ [('foo', 'bar'), ('bar', 'foo'), ('biz', 'baz')],
+ [('foo', 'bar'), ('bar', 'foo')]),
+
+ ('foo bar',
+ [('foo', ''), ('bar foo', ''), ('xfooyybarz', '')],
+ [('xfooyybarz', '')]),
+
+ ('foo%bar',
+ [('foo%bar', ''), ('foo bar', ''), ('foobar', '')],
+ [('foo%bar', '')]),
+
+ ('_',
+ [('a_b', ''), ('__a', ''), ('abc', '')],
+ [('a_b', ''), ('__a', '')]),
+
+ ('%',
+ [('\\foo', '\\bar')],
+ []),
+
+ ("can't",
+ [("can't touch this", ''), ('a', '')],
+ [("can't touch this", '')]),
+])
+def test_set_pattern(pattern, before, after, model_validator, hist):
+ """Validate the filtering and sorting results of set_pattern."""
+ for row in before:
+ hist.insert({'url': row[0], 'title': row[1], 'last_atime': 1})
+ cat = histcategory.HistoryCategory()
+ model_validator.set_model(cat)
+ cat.set_pattern(pattern)
+ model_validator.validate(after)
+
+
+@pytest.mark.parametrize('max_items, before, after', [
+ (-1, [
+ ('a', 'a', '2017-04-16'),
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ], [
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ('a', 'a', '2017-04-16'),
+ ]),
+ (3, [
+ ('a', 'a', '2017-04-16'),
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ], [
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ('a', 'a', '2017-04-16'),
+ ]),
+ (2, [
+ ('a', 'a', '2017-04-16'),
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ], [
+ ('b', 'b', '2017-06-16'),
+ ('c', 'c', '2017-05-16'),
+ ])
+])
+def test_sorting(max_items, before, after, model_validator, hist, config_stub):
+ """Validate the filtering and sorting results of set_pattern."""
+ config_stub.data['completion']['web-history-max-items'] = max_items
+ for url, title, atime in before:
+ timestamp = time.mktime(time.strptime(atime, '%Y-%m-%d'))
+ hist.insert({'url': url, 'title': title, 'last_atime': timestamp})
+ cat = histcategory.HistoryCategory()
+ model_validator.set_model(cat)
+ cat.set_pattern('')
+ model_validator.validate(after)
+
+
+def test_delete_cur_item(hist):
+ hist.insert({'url': 'foo', 'title': 'Foo'})
+ hist.insert({'url': 'bar', 'title': 'Bar'})
+ func = unittest.mock.Mock(spec=[])
+ cat = histcategory.HistoryCategory(delete_func=func)
+ cat.set_pattern('')
+ cat.delete_cur_item(cat.index(0, 0))
+ func.assert_called_with(['foo', 'Foo', ''])
+
+
+def test_delete_cur_item_no_func(hist):
+ hist.insert({'url': 'foo', 'title': 1})
+ hist.insert({'url': 'bar', 'title': 2})
+ cat = histcategory.HistoryCategory()
+ cat.set_pattern('')
+ with pytest.raises(cmdexc.CommandError, match='Cannot delete this item'):
+ cat.delete_cur_item(cat.index(0, 0))
diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py
index 4fab0c402..5b632eb3a 100644
--- a/tests/unit/completion/test_models.py
+++ b/tests/unit/completion/test_models.py
@@ -137,8 +137,10 @@ def bookmarks(bookmark_manager_stub):
@pytest.fixture
-def web_history(init_sql, stubs):
+def web_history(init_sql, stubs, config_stub):
"""Fixture which provides a web-history object."""
+ config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d',
+ 'web-history-max-items': -1}
stub = history.WebHistory()
objreg.register('web-history', stub)
yield stub
@@ -266,7 +268,7 @@ def test_bookmark_completion(qtmodeltester, bookmarks):
})
-def test_url_completion(qtmodeltester, config_stub, web_history_populated,
+def test_url_completion(qtmodeltester, web_history_populated,
quickmarks, bookmarks):
"""Test the results of url completion.
@@ -275,7 +277,6 @@ def test_url_completion(qtmodeltester, config_stub, web_history_populated,
- entries are sorted by access time
- only the most recent entry is included for each url
"""
- config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d'}
model = urlmodel.url()
model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
@@ -318,11 +319,10 @@ def test_url_completion(qtmodeltester, config_stub, web_history_populated,
('foo%bar', '', '%', 1),
('foobar', '', '%', 0),
])
-def test_url_completion_pattern(config_stub, web_history,
- quickmark_manager_stub, bookmark_manager_stub,
- url, title, pattern, rowcount):
+def test_url_completion_pattern(web_history, quickmark_manager_stub,
+ bookmark_manager_stub, url, title, pattern,
+ rowcount):
"""Test that url completion filters by url and title."""
- config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d'}
web_history.add_url(QUrl(url), title)
model = urlmodel.url()
model.set_pattern(pattern)
@@ -330,10 +330,9 @@ def test_url_completion_pattern(config_stub, web_history,
assert model.rowCount(model.index(2, 0)) == rowcount
-def test_url_completion_delete_bookmark(qtmodeltester, config_stub, bookmarks,
+def test_url_completion_delete_bookmark(qtmodeltester, bookmarks,
web_history, quickmarks):
"""Test deleting a bookmark from the url completion model."""
- config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d'}
model = urlmodel.url()
model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
@@ -353,11 +352,10 @@ def test_url_completion_delete_bookmark(qtmodeltester, config_stub, bookmarks,
assert len_before == len(bookmarks.marks) + 1
-def test_url_completion_delete_quickmark(qtmodeltester, config_stub,
+def test_url_completion_delete_quickmark(qtmodeltester,
quickmarks, web_history, bookmarks,
qtbot):
"""Test deleting a bookmark from the url completion model."""
- config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d'}
model = urlmodel.url()
model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
@@ -377,11 +375,10 @@ def test_url_completion_delete_quickmark(qtmodeltester, config_stub,
assert len_before == len(quickmarks.marks) + 1
-def test_url_completion_delete_history(qtmodeltester, config_stub,
+def test_url_completion_delete_history(qtmodeltester,
web_history_populated,
quickmarks, bookmarks):
"""Test deleting a history entry."""
- config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d'}
model = urlmodel.url()
model.set_pattern('')
qtmodeltester.data_display_may_return_none = True
@@ -598,14 +595,11 @@ def test_bind_completion(qtmodeltester, monkeypatch, stubs, config_stub,
})
-def test_url_completion_benchmark(benchmark, config_stub,
+def test_url_completion_benchmark(benchmark,
quickmark_manager_stub,
bookmark_manager_stub,
web_history):
"""Benchmark url completion."""
- config_stub.data['completion'] = {'timestamp-format': '%Y-%m-%d',
- 'web-history-max-items': 1000}
-
r = range(100000)
entries = {
'last_atime': list(r),
diff --git a/tests/unit/completion/test_sqlcategory.py b/tests/unit/completion/test_sqlcategory.py
deleted file mode 100644
index a9321e33e..000000000
--- a/tests/unit/completion/test_sqlcategory.py
+++ /dev/null
@@ -1,158 +0,0 @@
-# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
-
-# Copyright 2016-2017 Ryan Roden-Corrent (rcorre)
-#
-# This file is part of qutebrowser.
-#
-# qutebrowser is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# qutebrowser is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with qutebrowser. If not, see .
-
-"""Test SQL-based completions."""
-
-import unittest.mock
-
-import pytest
-
-from qutebrowser.misc import sql
-from qutebrowser.completion.models import sqlcategory
-from qutebrowser.commands import cmdexc
-
-
-pytestmark = pytest.mark.usefixtures('init_sql')
-
-
-@pytest.mark.parametrize('sort_by, sort_order, data, expected', [
- (None, None,
- [('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
- [('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')]),
-
- ('a', 'asc',
- [('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
- [('A', 'F', 'C'), ('B', 'C', 'D'), ('C', 'A', 'G')]),
-
- ('a', 'desc',
- [('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
- [('C', 'A', 'G'), ('B', 'C', 'D'), ('A', 'F', 'C')]),
-
- ('b', 'asc',
- [('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
- [('C', 'A', 'G'), ('B', 'C', 'D'), ('A', 'F', 'C')]),
-
- ('b', 'desc',
- [('B', 'C', 'D'), ('A', 'F', 'C'), ('C', 'A', 'G')],
- [('A', 'F', 'C'), ('B', 'C', 'D'), ('C', 'A', 'G')]),
-
- ('c', 'asc',
- [('B', 'C', 2), ('A', 'F', 0), ('C', 'A', 1)],
- [('A', 'F', 0), ('C', 'A', 1), ('B', 'C', 2)]),
-
- ('c', 'desc',
- [('B', 'C', 2), ('A', 'F', 0), ('C', 'A', 1)],
- [('B', 'C', 2), ('C', 'A', 1), ('A', 'F', 0)]),
-])
-def test_sorting(sort_by, sort_order, data, expected, model_validator):
- table = sql.SqlTable('Foo', ['a', 'b', 'c'])
- for row in data:
- table.insert({'a': row[0], 'b': row[1], 'c': row[2]})
- cat = sqlcategory.SqlCategory('Foo', filter_fields=['a'], sort_by=sort_by,
- sort_order=sort_order)
- model_validator.set_model(cat)
- cat.set_pattern('')
- model_validator.validate(expected)
-
-
-@pytest.mark.parametrize('pattern, filter_cols, before, after', [
- ('foo', [0],
- [('foo', '', ''), ('bar', '', ''), ('aafobbb', '', '')],
- [('foo',)]),
-
- ('foo', [0],
- [('baz', 'bar', 'foo'), ('foo', '', ''), ('bar', 'foo', '')],
- [('foo', '', '')]),
-
- ('foo', [0],
- [('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')],
- [('fooa', '', ''), ('foob', '', ''), ('fooc', '', '')]),
-
- ('foo', [1],
- [('foo', 'bar', ''), ('bar', 'foo', '')],
- [('bar', 'foo', '')]),
-
- ('foo', [0, 1],
- [('foo', 'bar', ''), ('bar', 'foo', ''), ('biz', 'baz', 'foo')],
- [('foo', 'bar', ''), ('bar', 'foo', '')]),
-
- ('foo', [0, 1, 2],
- [('foo', '', ''), ('bar', '', ''), ('baz', 'bar', 'foo')],
- [('foo', '', ''), ('baz', 'bar', 'foo')]),
-
- ('foo bar', [0],
- [('foo', '', ''), ('bar foo', '', ''), ('xfooyybarz', '', '')],
- [('xfooyybarz', '', '')]),
-
- ('foo%bar', [0],
- [('foo%bar', '', ''), ('foo bar', '', ''), ('foobar', '', '')],
- [('foo%bar', '', '')]),
-
- ('_', [0],
- [('a_b', '', ''), ('__a', '', ''), ('abc', '', '')],
- [('a_b', '', ''), ('__a', '', '')]),
-
- ('%', [0, 1],
- [('\\foo', '\\bar', '')],
- []),
-
- ("can't", [0],
- [("can't touch this", '', ''), ('a', '', '')],
- [("can't touch this", '', '')]),
-])
-def test_set_pattern(pattern, filter_cols, before, after, model_validator):
- """Validate the filtering and sorting results of set_pattern."""
- table = sql.SqlTable('Foo', ['a', 'b', 'c'])
- for row in before:
- table.insert({'a': row[0], 'b': row[1], 'c': row[2]})
- filter_fields = [['a', 'b', 'c'][i] for i in filter_cols]
- cat = sqlcategory.SqlCategory('Foo', filter_fields=filter_fields)
- model_validator.set_model(cat)
- cat.set_pattern(pattern)
- model_validator.validate(after)
-
-
-def test_select(model_validator):
- table = sql.SqlTable('Foo', ['a', 'b', 'c'])
- table.insert({'a': 'foo', 'b': 'bar', 'c': 'baz'})
- cat = sqlcategory.SqlCategory('Foo', filter_fields=['a'], select='b, c, a')
- model_validator.set_model(cat)
- cat.set_pattern('')
- model_validator.validate([('bar', 'baz', 'foo')])
-
-
-def test_delete_cur_item():
- table = sql.SqlTable('Foo', ['a', 'b'])
- table.insert({'a': 'foo', 'b': 1})
- table.insert({'a': 'bar', 'b': 2})
- func = unittest.mock.Mock(spec=[])
- cat = sqlcategory.SqlCategory('Foo', filter_fields=['a'], delete_func=func)
- cat.set_pattern('')
- cat.delete_cur_item(cat.index(0, 0))
- func.assert_called_with(['foo', 1])
-
-
-def test_delete_cur_item_no_func():
- table = sql.SqlTable('Foo', ['a', 'b'])
- table.insert({'a': 'foo', 'b': 1})
- table.insert({'a': 'bar', 'b': 2})
- cat = sqlcategory.SqlCategory('Foo', filter_fields=['a'])
- cat.set_pattern('')
- with pytest.raises(cmdexc.CommandError, match='Cannot delete this item'):
- cat.delete_cur_item(cat.index(0, 0))