diff --git a/.analysis-options b/.analysis-options
new file mode 100644
index 00000000..518eb901
--- /dev/null
+++ b/.analysis-options
@@ -0,0 +1,2 @@
+analyzer:
+ strong-mode: true
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 4d2a4d6d..99e7978e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,47 @@ pubspec.lock
# Directory created by dartdoc
# If you don't generate documentation locally you can remove this line.
doc/api/
+### JetBrains template
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff:
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/dictionaries
+
+# Sensitive or high-churn files:
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.xml
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+
+# Gradle:
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Mongo Explorer plugin:
+.idea/**/mongoSettings.xml
+
+## File-based project format:
+*.iws
+
+## Plugin-specific files:
+
+# IntelliJ
+/out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..94a25f7f
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 00000000..de2210c9
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1 @@
+language: dart
\ No newline at end of file
diff --git a/README.md b/README.md
index 7a52774a..47d1a526 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,78 @@
# paginate
-Platform-agnostic pagination library, with custom support for the Angel framework.
+[![version 1.0.0](https://img.shields.io/badge/pub-v1.0.0-brightgreen.svg)](https://pub.dartlang.org/packages/angel_paginate)
+[![build status](https://travis-ci.org/angel-dart/paginate.svg)](https://travis-ci.org/angel-dart/paginate)
+
+Platform-agnostic pagination library, with custom support for the
+[Angel framework](https://github.com/angel-dart/angel).
+
+# Installation
+In your `pubspec.yaml` file:
+
+```yaml
+dependencies:
+ angel_paginate: ^1.0.0
+```
+
+# Usage
+This library exports a `Paginator`, which can be used to efficiently produce
+instances of `PaginationResult`. Pagination results, when serialized to JSON, look like
+this:
+
+```json
+{
+ "total" : 75,
+ "items_per_page" : 10,
+ "previous_page" : 3,
+ "current_page" : 4,
+ "next_page" : 5,
+ "start_index" : 30,
+ "end_index" : 39,
+ "data" : [""]
+}
+```
+
+Results can be parsed from Maps using the `PaginationResult.fromMap` constructor, and
+serialized via their `toJson()` method.
+
+To create a paginator:
+
+```dart
+import 'package:angel_paginate/angel_paginate.dart';
+
+main() {
+ var p = new Paginator(iterable);
+
+ // Get the current page (default: page 1)
+ var page = p.current;
+ print(page.total);
+ print(page.startIndex);
+ print(page.data); // The actual items on this page.
+ p.next(); // Advance a page
+ p.goBack(); // Back one page
+ p.goToPage(10); // Go to page number (1-based, not a 0-based index)
+}
+```
+
+The entire Paginator API is documented, so check out the DartDocs.
+
+Paginators by default cache paginations, to improve performance as you shift through pages.
+This can be especially helpful in a client-side application where your UX involves a fast
+response time, i.e. a search page.
+
+## Use With Angel
+Naturally, a library named `angel_paginate` has special provisions for the
+[Angel framework](https://github.com/angel-dart/angel).
+
+In `package:angel_paginate/server.dart`, a function called `paginate` generates
+pagination service hooks for you. If the result of a hooked service event is an `Iterable`,
+it will be paginated. This is convenient because it works with any data store, whether it
+be MongoDB, RethinkDB, an in-memory store, or something else entirely.
+
+```dart
+configureServer(Angel app) {
+ var service = app.service('api/foo') as HookedService;
+ service.afterIndexed.listen(paginate(itemsPerPage: 10));
+}
+```
+
+See `test/server_test.dart` for examples of usage with Angel.
\ No newline at end of file
diff --git a/lib/angel_paginate.dart b/lib/angel_paginate.dart
new file mode 100644
index 00000000..55d6e1ec
--- /dev/null
+++ b/lib/angel_paginate.dart
@@ -0,0 +1,190 @@
+/// Efficiently paginates collections of items in an object-oriented manner.
+class Paginator {
+ final Map> _cache = {};
+ PaginationResult _current;
+ int _page = 0;
+
+ /// The collection of items to be paginated.
+ final Iterable _items;
+
+ /// The maximum number of items to be shown per page.
+ final int itemsPerPage;
+
+ /// If `true` (default), then the results of paginations will be saved by page number.
+ ///
+ /// For example, you would only have to paginate page 1 once. Future calls would return a cached version.
+ final bool useCache;
+
+ Paginator(this._items, {this.itemsPerPage: 5, this.useCache: true});
+
+ /// Returns `true` if there are more items at lesser page indices than the current one.
+ bool get canGoBack => _page > 0;
+
+ /// Returns `true` if there are more items at greater page indices than the current one.
+ bool get canGoForward => _page < _lastPage();
+
+ /// The current page index.
+ int get index => _page;
+
+ /// Returns the greatest possible page number for this collection, given the number of [itemsPerPage].
+ int get lastPageNumber => _lastPage();
+
+ /// The current page number. This is not the same as [index].
+ ///
+ /// This getter will return user-friendly numbers. The lowest value it will ever return is `1`.
+ int get pageNumber => _page < 1 ? 1 : (_page + 1);
+
+ /// Fetches the current page. This will be cached until [back] or [next] is called.
+ ///
+ /// If [useCache] is `true` (default), then computations will be cached even after the page changes.
+ PaginationResult get current {
+ if (_current != null)
+ return _current;
+ else
+ return _current = _getPage();
+ }
+
+ PaginationResult _computePage() {
+ var len = _items.length;
+ var it = _items.skip(_page * (itemsPerPage ?? 5));
+ var offset = len - it.length;
+ it = it.take(itemsPerPage);
+ var last = _lastPage();
+ // print('cur: $_page, last: $last');
+ return new _PaginationResultImpl(it,
+ currentPage: _page + 1,
+ previousPage: _page <= 0 ? -1 : _page,
+ nextPage: _page >= last - 1 ? -1 : _page + 2,
+ startIndex: offset,
+ endIndex: offset + it.length - 1,
+ itemsPerPage: itemsPerPage,
+ total: len);
+ }
+
+ PaginationResult _getPage() {
+ if (useCache != false)
+ return _cache.putIfAbsent(_page, () => _computePage());
+ else
+ return _computePage();
+ }
+
+ int _lastPage() {
+ var n = (_items.length / itemsPerPage).round();
+ // print('items: ${_items.length}');
+ // print('per page: $itemsPerPage');
+ // print('quotient: $n');
+ var remainder = _items.length - (n * itemsPerPage);
+ // print('remainder: $remainder');
+ return (remainder <= 0) ? n : n + 1;
+ }
+
+ /// Attempts to go the specified page. If it fails, then it will remain on the current page.
+ ///
+ /// Keep in mind - this just not be a zero-based index, but a one-based page number. The lowest
+ /// allowed value is `1`.
+ void goToPage(int page) {
+ if (page > 0 && page <= _lastPage()) {
+ _page = page - 1;
+ _current = null;
+ }
+ }
+
+ /// Moves the paginator back one page, if possible.
+ void back() {
+ if (_page > 0) {
+ _page--;
+ _current = null;
+ }
+ }
+
+ /// Advances the paginator one page, if possible.
+ void next() {
+ if (_page < _lastPage()) {
+ _page++;
+ _current = null;
+ }
+ }
+}
+
+/// Stores the result of a pagination.
+abstract class PaginationResult {
+ factory PaginationResult.fromMap(Map map) =>
+ new _PaginationResultImpl(map['data'],
+ currentPage: map['current_page'],
+ endIndex: map['end_index'],
+ itemsPerPage: map['items_per_page'],
+ nextPage: map['next_page'],
+ previousPage: map['previous_page'],
+ startIndex: map['start_index'],
+ total: map['total']);
+
+ List get data;
+
+ int get currentPage;
+
+ int get previousPage;
+
+ int get nextPage;
+
+ int get itemsPerPage;
+
+ int get total;
+
+ int get startIndex;
+
+ int get endIndex;
+
+ Map toJson();
+}
+
+class _PaginationResultImpl implements PaginationResult {
+ final Iterable _data;
+ Iterable _cachedData;
+
+ @override
+ final int currentPage;
+
+ _PaginationResultImpl(this._data,
+ {this.currentPage,
+ this.endIndex,
+ this.itemsPerPage,
+ this.nextPage,
+ this.previousPage,
+ this.startIndex,
+ this.total});
+
+ @override
+ List get data => _cachedData ?? (_cachedData = new List.from(_data));
+
+ @override
+ final int endIndex;
+
+ @override
+ final int itemsPerPage;
+
+ @override
+ final int nextPage;
+
+ @override
+ final int previousPage;
+
+ @override
+ final int startIndex;
+
+ @override
+ final int total;
+
+ @override
+ Map toJson() {
+ return {
+ 'total': total,
+ 'items_per_page': itemsPerPage,
+ 'previous_page': previousPage,
+ 'current_page': currentPage,
+ 'next_page': nextPage,
+ 'start_index': startIndex,
+ 'end_index': endIndex,
+ 'data': data
+ };
+ }
+}
diff --git a/lib/server.dart b/lib/server.dart
new file mode 100644
index 00000000..8ca94321
--- /dev/null
+++ b/lib/server.dart
@@ -0,0 +1,49 @@
+import 'package:angel_framework/angel_framework.dart';
+import 'angel_paginate.dart';
+export 'angel_paginate.dart';
+
+/// Paginates the results of service events.
+///
+/// Users can add a `page` to the query to display a certain page, i.e. `http://foo.com/api/todos?page=5`.
+///
+/// Users can also add a `$limit` to the query to display more or less items than specified in [itemsPerPage] (default: `5`).
+/// If [maxItemsPerPage] is set, then even if the query contains a `$limit` parameter, it will be limited to the maximum.
+HookedServiceEventListener paginate(
+ {int itemsPerPage, int maxItemsPerPage}) {
+ return (HookedServiceEvent e) {
+ if (e.isBefore) throw new UnsupportedError(
+ '`package:angel_paginate` can only be run as an after hook.');
+ if (e.result is! Iterable) return;
+
+ int page = 0,
+ nItems = itemsPerPage;
+
+ if (e.params.containsKey('query') && e.params['query'] is Map) {
+ var query = e.params['query'] as Map;
+
+ if (query.containsKey('page')) {
+ try {
+ page = int.parse(query['page']?.toString());
+ } catch (e) {
+ // Fail silently...
+ }
+ }
+
+ if (query.containsKey(r'$limit')) {
+ try {
+ var lim = int.parse(query[r'$limit']?.toString());
+ if (lim > 0 && (maxItemsPerPage == null || lim <= maxItemsPerPage))
+ nItems = lim;
+ } catch (e) {
+ // Fail silently...
+ }
+ }
+ }
+
+
+ var paginator = new Paginator(
+ e.result, itemsPerPage: nItems)
+ ..goToPage(page);
+ e.result = paginator.current;
+ };
+}
\ No newline at end of file
diff --git a/pubspec.yaml b/pubspec.yaml
new file mode 100644
index 00000000..3338f1c6
--- /dev/null
+++ b/pubspec.yaml
@@ -0,0 +1,13 @@
+name: angel_paginate
+version: 1.0.0
+description: Platform-agnostic pagination library, with custom support for the Angel framework.
+author: Tobe O
+homepage: https://github.com/angel-dart/paginate
+environment:
+ sdk: ">=1.19.0"
+dependencies:
+ angel_framework: ^1.0.0
+dev_dependencies:
+ angel_diagnostics: ^1.0.0
+ angel_test: ^1.0.0
+ test: ^0.12.15
\ No newline at end of file
diff --git a/test/paginate_test.dart b/test/paginate_test.dart
new file mode 100644
index 00000000..6c94dc93
--- /dev/null
+++ b/test/paginate_test.dart
@@ -0,0 +1,105 @@
+import 'package:angel_paginate/angel_paginate.dart';
+import 'package:test/test.dart';
+
+// Count-down from 100, then 101 at the end...
+final List DATA = new List.generate(100, (i) => 100 - i)
+ ..add(101);
+
+main() {
+ group('cache', () {
+ var cached = new Paginator(DATA),
+ uncached = new Paginator(DATA, useCache: false);
+
+ test('always cache current', () {
+ expect(cached.current, cached.current);
+ expect(uncached.current, uncached.current);
+ });
+
+ test('only cache prev/next if useCache != false', () {
+ var cached1 = cached.current;
+ cached.goToPage(4);
+ var cached4 = cached.current;
+ cached.goToPage(1);
+ expect(cached.current, cached1);
+ cached.goToPage(4);
+ expect(cached.current, cached4);
+
+ var uncached1 = uncached.current;
+ uncached.goToPage(4);
+ var uncached4 = uncached.current;
+ uncached.goToPage(1);
+ expect(uncached.current, isNot(uncached1));
+ uncached.goToPage(4);
+ expect(uncached.current, isNot(uncached4));
+ });
+ });
+
+ test('default state', () {
+ var paginator = new Paginator(DATA);
+ expect(paginator.index, 0);
+ expect(paginator.pageNumber, 1);
+ expect(paginator.itemsPerPage, 5);
+ expect(paginator.useCache, true);
+ expect(paginator.canGoBack, false);
+ expect(paginator.canGoForward, true);
+ expect(paginator.lastPageNumber, 21);
+ });
+
+ group('paginate', () {
+ test('first page', () {
+ var paginator = new Paginator(DATA);
+ expect(paginator.pageNumber, 1);
+ var r = paginator.current;
+ print(r.toJson());
+ expect(r.total, DATA.length);
+ expect(r.itemsPerPage, 5);
+ expect(r.previousPage, -1);
+ expect(r.currentPage, 1);
+ expect(r.nextPage, 2);
+ expect(r.startIndex, 0);
+ expect(r.endIndex, 4);
+ expect(r.data, DATA.skip(r.startIndex).take(r.itemsPerPage).toList());
+ });
+ });
+
+ test('third page', () {
+ var paginator = new Paginator(DATA)
+ ..goToPage(3);
+ expect(paginator.pageNumber, 3);
+ var r = paginator.current;
+ print(r.toJson());
+ expect(r.total, DATA.length);
+ expect(r.itemsPerPage, 5);
+ expect(r.previousPage, 2);
+ expect(r.currentPage, 3);
+ expect(r.nextPage, 4);
+ expect(r.startIndex, 10);
+ expect(r.endIndex, 14);
+ expect(r.data, DATA.skip(r.startIndex).take(r.itemsPerPage).toList());
+ });
+
+ test('last page', () {
+ var paginator = new Paginator(DATA);
+ paginator.goToPage(paginator.lastPageNumber);
+ var r = paginator.current;
+ expect(r.total, DATA.length);
+ expect(r.itemsPerPage, 5);
+ expect(r.previousPage, paginator.lastPageNumber - 1);
+ expect(r.currentPage, paginator.lastPageNumber);
+ expect(r.nextPage, -1);
+ expect(r.startIndex, (paginator.lastPageNumber - 1) * 5);
+ expect(r.endIndex, r.startIndex);
+ expect(r.data, [DATA.last]);
+ expect(r.data, DATA.skip(r.startIndex).take(r.itemsPerPage).toList());
+ });
+
+ test('dump pages', () {
+ var paginator = new Paginator(DATA);
+ print('${paginator.lastPageNumber} page(s) of data:');
+
+ do {
+ print(' * Page #${paginator.pageNumber}: ${paginator.current.data}');
+ paginator.next();
+ } while(paginator.canGoForward);
+ });
+}
diff --git a/test/server_test.dart b/test/server_test.dart
new file mode 100644
index 00000000..ca714e7c
--- /dev/null
+++ b/test/server_test.dart
@@ -0,0 +1,115 @@
+import 'package:angel_client/angel_client.dart' as c;
+import 'package:angel_diagnostics/angel_diagnostics.dart';
+import 'package:angel_framework/angel_framework.dart';
+import 'package:angel_paginate/server.dart';
+import 'package:angel_test/angel_test.dart';
+import 'package:test/test.dart';
+
+final List DATA = new List.filled(50, {'foo': 'bar'}, growable: true)
+ ..addAll(new List.filled(25, {'bar': 'baz'}));
+
+main() {
+ group('no max', () {
+ Angel app;
+ TestClient client;
+ c.Service dataService;
+
+ setUp(() async {
+ app = new Angel()
+ ..use('/data', new AnonymousService(index: ([p]) async => DATA));
+ await app.configure(logRequests());
+ var service = app.service('data') as HookedService;
+ service.afterIndexed.listen(paginate(itemsPerPage: 10));
+
+ client = await connectTo(app);
+ dataService = client.service('data');
+ });
+
+ tearDown(() => client.close());
+
+ test('first page', () async {
+ var response = await dataService
+ .index()
+ .then((m) => new PaginationResult.fromMap(m));
+ print(response.toJson());
+
+ expect(response.total, DATA.length);
+ expect(response.itemsPerPage, 10);
+ expect(response.startIndex, 0);
+ expect(response.endIndex, 9);
+ expect(response.previousPage, -1);
+ expect(response.currentPage, 1);
+ expect(response.nextPage, 2);
+ expect(response.data, DATA.take(10).toList());
+ });
+
+ test('third page', () async {
+ var response = await dataService.index({
+ 'query': {'page': 3}
+ }).then((m) => new PaginationResult.fromMap(m));
+ print(response.toJson());
+
+ expect(response.total, DATA.length);
+ expect(response.itemsPerPage, 10);
+ expect(response.startIndex, 20);
+ expect(response.endIndex, 29);
+ expect(response.previousPage, 2);
+ expect(response.currentPage, 3);
+ expect(response.nextPage, 4);
+ expect(response.data, DATA.skip(20).take(10).toList());
+ });
+
+ test('custom limit', () async {
+ var response = await dataService.index({
+ 'query': {'page': 4, r'$limit': 5}
+ }).then((m) => new PaginationResult.fromMap(m));
+ print(response.toJson());
+
+ expect(response.total, DATA.length);
+ expect(response.itemsPerPage, 5);
+ expect(response.startIndex, 15);
+ expect(response.endIndex, 19);
+ expect(response.previousPage, 3);
+ expect(response.currentPage, 4);
+ expect(response.nextPage, 5);
+ expect(response.data, DATA.skip(15).take(5).toList());
+ });
+ });
+
+ group('max 15', () {
+ Angel app;
+ TestClient client;
+ c.Service dataService;
+
+ setUp(() async {
+ app = new Angel()
+ ..use('/data', new AnonymousService(index: ([p]) async => DATA));
+ await app.configure(logRequests());
+ var service = app.service('data') as HookedService;
+ service.afterIndexed.listen(
+ paginate(itemsPerPage: 10, maxItemsPerPage: 15));
+
+ client = await connectTo(app);
+ dataService = client.service('data');
+ });
+
+ tearDown(() => client.close());
+
+ test('exceed max', () async {
+ var response = await dataService.index({
+ 'query': {'page': 4, r'$limit': 30}
+ }).then((m) => new PaginationResult.fromMap(m));
+ print(response.toJson());
+
+ // Should default to 10 items per page :)
+ expect(response.total, DATA.length);
+ expect(response.itemsPerPage, 10);
+ expect(response.startIndex, 30);
+ expect(response.endIndex, 39);
+ expect(response.previousPage, 3);
+ expect(response.currentPage, 4);
+ expect(response.nextPage, 5);
+ expect(response.data, DATA.skip(30).take(10).toList());
+ });
+ });
+}