diff --git a/beets/dbcore/query.py b/beets/dbcore/query.py index 4d94f4e356..fbe6626c7c 100644 --- a/beets/dbcore/query.py +++ b/beets/dbcore/query.py @@ -562,6 +562,9 @@ class Period(object): ('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M'), # minute ('%Y-%m-%dT%H:%M:%S', '%Y-%m-%d %H:%M:%S') # second ) + relative_units = {'y': 365, 'm': 30, 'w': 7, 'd': 1} + relative_re = '(?P[+|-]?)(?P[0-9]+)' + \ + '(?P[y|m|w|d])' def __init__(self, date, precision): """Create a period with the given date (a `datetime` object) and @@ -575,9 +578,20 @@ def __init__(self, date, precision): @classmethod def parse(cls, string): - """Parse a date and return a `Period` object, or `None` if the + """Parse a date and return a `Period` object or `None` if the string is empty, or raise an InvalidQueryArgumentValueError if - the string could not be parsed to a date. + the string cannot be parsed to a date. + + The date may be absolute or relative. Absolute dates look like + `YYYY`, or `YYYY-MM-DD`, or `YYYY-MM-DD HH:MM:SS`, etc. Relative + dates have three parts: + + - Optionally, a ``+`` or ``-`` sign indicating the future or the + past. The default is the future. + - A number: how much to add or subtract. + - A letter indicating the unit: days, weeks, months or years + (``d``, ``w``, ``m`` or ``y``). A "month" is exactly 30 days + and a "year" is exactly 365 days. """ def find_date_and_format(string): @@ -593,10 +607,27 @@ def find_date_and_format(string): if not string: return None + + # Check for a relative date. + match_dq = re.match(cls.relative_re, string) + if match_dq: + sign = match_dq.group('sign') + quantity = match_dq.group('quantity') + timespan = match_dq.group('timespan') + + # Add or subtract the given amount of time from the current + # date. + multiplier = -1 if sign == '-' else 1 + days = cls.relative_units[timespan] + date = datetime.now() + \ + timedelta(days=int(quantity) * days) * multiplier + return cls(date, cls.precisions[5]) + + # Check for an absolute date. date, ordinal = find_date_and_format(string) if date is None: raise InvalidQueryArgumentValueError(string, - 'a valid datetime string') + 'a valid date/time string') precision = cls.precisions[ordinal] return cls(date, precision) diff --git a/docs/changelog.rst b/docs/changelog.rst index 01cc27cc56..d692c4bf86 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,9 @@ Features: * :ref:`Date queries ` can now include times, so you can filter your music down to the second. Thanks to :user:`discopatrick`. :bug:`2506` :bug:`2528` +* :ref:`Date queries ` can also be *relative*. You can say + ``added:-1w..`` to match music added in the last week, for example. Thanks + to :user:`euri10`. :bug:`2598` * A new :doc:`/plugins/gmusic` lets you interact with your Google Play Music library. Thanks to :user:`tigranl`. :bug:`2553` :bug:`2586` * :doc:`/plugins/replaygain`: We now keep R128 data in separate tags from diff --git a/docs/reference/query.rst b/docs/reference/query.rst index 112c1966de..d103d9aec8 100644 --- a/docs/reference/query.rst +++ b/docs/reference/query.rst @@ -217,6 +217,25 @@ queries do the same thing:: $ beet ls 'added:2008-12-01t22:45:20' $ beet ls 'added:2008-12-01 22:45:20' +You can also use *relative* dates. For example, ``-3w`` means three weeks ago, +and ``+4d`` means four days in the future. A relative date has three parts: + +- Either ``+`` or ``-``, to indicate the past or the future. The sign is + optional; if you leave this off, it defaults to the future. +- A number. +- A letter indicating the unit: ``d``, ``w``, ``m`` or ``y``, meaning days, + weeks, months or years. (A "month" is always 30 days and a "year" is always + 365 days.) + +Here's an example that finds all the albums added since last week:: + + $ beet ls -a 'added:-1w..' + +And here's an example that lists items added in a two-week period starting +four weeks ago:: + + $ beet ls 'added:-6w..-4w' + .. _not_query: Query Term Negation diff --git a/test/test_datequery.py b/test/test_datequery.py index ba88570840..7b7776711e 100644 --- a/test/test_datequery.py +++ b/test/test_datequery.py @@ -18,7 +18,7 @@ from __future__ import division, absolute_import, print_function from test import _common -from datetime import datetime +from datetime import datetime, timedelta import unittest import time from beets.dbcore.query import _parse_periods, DateInterval, DateQuery,\ @@ -29,6 +29,10 @@ def _date(string): return datetime.strptime(string, '%Y-%m-%dT%H:%M:%S') +def _datepattern(datetimedate): + return datetimedate.strftime('%Y-%m-%dT%H:%M:%S') + + class DateIntervalTest(unittest.TestCase): def test_year_precision_intervals(self): self.assertContains('2000..2001', '2000-01-01T00:00:00') @@ -44,6 +48,9 @@ def test_year_precision_intervals(self): self.assertContains('..2001', '2001-12-31T23:59:59') self.assertExcludes('..2001', '2002-01-01T00:00:00') + self.assertContains('-1d..1d', _datepattern(datetime.now())) + self.assertExcludes('-2d..-1d', _datepattern(datetime.now())) + def test_day_precision_intervals(self): self.assertContains('2000-06-20..2000-06-20', '2000-06-20T00:00:00') self.assertContains('2000-06-20..2000-06-20', '2000-06-20T10:20:30') @@ -161,6 +168,87 @@ def test_single_day_nonmatch_fast(self): self.assertEqual(len(matched), 0) +class DateQueryTestRelative(_common.LibTestCase): + def setUp(self): + super(DateQueryTestRelative, self).setUp() + self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M')) + self.i.store() + + def test_single_month_match_fast(self): + query = DateQuery('added', datetime.now().strftime('%Y-%m')) + matched = self.lib.items(query) + self.assertEqual(len(matched), 1) + + def test_single_month_nonmatch_fast(self): + query = DateQuery('added', (datetime.now() + timedelta(days=30)) + .strftime('%Y-%m')) + matched = self.lib.items(query) + self.assertEqual(len(matched), 0) + + def test_single_month_match_slow(self): + query = DateQuery('added', datetime.now().strftime('%Y-%m')) + self.assertTrue(query.match(self.i)) + + def test_single_month_nonmatch_slow(self): + query = DateQuery('added', (datetime.now() + timedelta(days=30)) + .strftime('%Y-%m')) + self.assertFalse(query.match(self.i)) + + def test_single_day_match_fast(self): + query = DateQuery('added', datetime.now().strftime('%Y-%m-%d')) + matched = self.lib.items(query) + self.assertEqual(len(matched), 1) + + def test_single_day_nonmatch_fast(self): + query = DateQuery('added', (datetime.now() + timedelta(days=1)) + .strftime('%Y-%m-%d')) + matched = self.lib.items(query) + self.assertEqual(len(matched), 0) + + +class DateQueryTestRelativeMore(_common.LibTestCase): + def setUp(self): + super(DateQueryTestRelativeMore, self).setUp() + self.i.added = _parsetime(datetime.now().strftime('%Y-%m-%d %H:%M')) + self.i.store() + + def test_relative(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '-4' + timespan + '..+4' + timespan) + matched = self.lib.items(query) + self.assertEqual(len(matched), 1) + + def test_relative_fail(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '-2' + timespan + '..-1' + timespan) + matched = self.lib.items(query) + self.assertEqual(len(matched), 0) + + def test_start_relative(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '-4' + timespan + '..') + matched = self.lib.items(query) + self.assertEqual(len(matched), 1) + + def test_start_relative_fail(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '4' + timespan + '..') + matched = self.lib.items(query) + self.assertEqual(len(matched), 0) + + def test_end_relative(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '..+4' + timespan) + matched = self.lib.items(query) + self.assertEqual(len(matched), 1) + + def test_end_relative_fail(self): + for timespan in ['d', 'w', 'm', 'y']: + query = DateQuery('added', '..-4' + timespan) + matched = self.lib.items(query) + self.assertEqual(len(matched), 0) + + class DateQueryConstructTest(unittest.TestCase): def test_long_numbers(self): with self.assertRaises(InvalidQueryArgumentValueError):