Stat Polish (#1775)

* SeriesGroup tag can now have comma separated value to allow a series to be a part of multiple collections.

* Added a missing unit test

* Refactored how collection tags are created to work in the scan loop reliably.

* Added a unit test for RemoveTagsWithoutSeries

* Fixed a bug in reading list title generation to avoid Volume 0 if the underlying file had a title set. Fixed a misconfigured unit test.

* On User stats page, don't show the user selector on reading history, despite if youre an admin. Cleaned up how we show days with 0 reading events to be more clear.

* Refactored the name of a component to reflect what it does

* Removed plugin not using

* Fix an issue where coalescing a key in epub might have multiple html files ending with the key. In this case, let's take the first.

* Added PikaPods to the Readme

* Tried to fix layout shift for charts, but need Robbie's help

* Chart styling

# Added:
- Added: Added styling to force charts into their respective containers.

# Removed:
- Removed: Removed code blocking charts from being visible on mobile.

* Merge conflict

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
Joe Milazzo 2023-02-04 02:53:21 -08:00 committed by GitHub
parent c60fdcf6e0
commit cad897015c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 258 additions and 214 deletions

View File

@ -731,7 +731,7 @@ public class BookService : IBookService
if (mappings.ContainsKey(CleanContentKeys(key))) return key; if (mappings.ContainsKey(CleanContentKeys(key))) return key;
// Fallback to searching for key (bad epub metadata) // Fallback to searching for key (bad epub metadata)
var correctedKey = book.Content.Html.Keys.SingleOrDefault(s => s.EndsWith(key)); var correctedKey = book.Content.Html.Keys.FirstOrDefault(s => s.EndsWith(key));
if (!string.IsNullOrEmpty(correctedKey)) if (!string.IsNullOrEmpty(correctedKey))
{ {
key = correctedKey; key = correctedKey;

View File

@ -63,19 +63,6 @@ public class StatisticService : IStatisticService
.Where(p => libraryIds.Contains(p.LibraryId)) .Where(p => libraryIds.Contains(p.LibraryId))
.SumAsync(p => p.PagesRead); .SumAsync(p => p.PagesRead);
// var ids = await _context.AppUserProgresses
// .Where(p => p.AppUserId == userId)
// .Where(p => libraryIds.Contains(p.LibraryId))
// .Where(p => p.PagesRead > 0)
// .Select(p => new {p.ChapterId, p.SeriesId})
// .ToListAsync();
//var chapterIds = ids.Select(id => id.ChapterId);
// var timeSpentReading = await _context.Chapter
// .Where(c => chapterIds.Contains(c.Id))
// .SumAsync(c => c.AvgHoursToRead);
var timeSpentReading = await TimeSpentReadingForUsersAsync(new List<int>() {userId}, libraryIds); var timeSpentReading = await TimeSpentReadingForUsersAsync(new List<int>() {userId}, libraryIds);
var totalWordsRead = (long) Math.Round(await _context.AppUserProgresses var totalWordsRead = (long) Math.Round(await _context.AppUserProgresses
@ -364,12 +351,12 @@ public class StatisticService : IStatisticService
if (days > 0) if (days > 0)
{ {
var date = DateTime.Now.AddDays(days * -1); var date = DateTime.Now.AddDays(days * -1);
query = query.Where(x => x.appUserProgresses.LastModified >= date && x.appUserProgresses.Created >= date); query = query.Where(x => x.appUserProgresses.LastModified >= date);
} }
var results = await query.GroupBy(x => new var results = await query.GroupBy(x => new
{ {
Day = x.appUserProgresses.Created.Date, Day = x.appUserProgresses.LastModified.Date,
x.series.Format x.series.Format
}) })
.Select(g => new PagesReadOnADayCount<DateTime> .Select(g => new PagesReadOnADayCount<DateTime>
@ -389,7 +376,25 @@ public class StatisticService : IStatisticService
if (results.Any(d => d.Value == date)) continue; if (results.Any(d => d.Value == date)) continue;
results.Add(new PagesReadOnADayCount<DateTime>() results.Add(new PagesReadOnADayCount<DateTime>()
{ {
Format = MangaFormat.Unknown, Format = MangaFormat.Archive,
Value = date,
Count = 0
});
results.Add(new PagesReadOnADayCount<DateTime>()
{
Format = MangaFormat.Epub,
Value = date,
Count = 0
});
results.Add(new PagesReadOnADayCount<DateTime>()
{
Format = MangaFormat.Pdf,
Value = date,
Count = 0
});
results.Add(new PagesReadOnADayCount<DateTime>()
{
Format = MangaFormat.Image,
Value = date, Value = date,
Count = 0 Count = 0
}); });

View File

@ -1,6 +1,5 @@
using System; using System;
using System.IO; using System.IO;
using System.Linq;
using System.Text.Json; using System.Text.Json;
using Kavita.Common.EnvironmentInfo; using Kavita.Common.EnvironmentInfo;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;

View File

@ -97,6 +97,10 @@ We would like to extend a big thank you to [<img src="/Logo/hosting-sponsor.png"
We would like to extend a big thank you to [Huntr](https://huntr.dev/repos/kareadita/kavita) who has worked with Kavita in reporting security vulnerabilities. If you are interested in We would like to extend a big thank you to [Huntr](https://huntr.dev/repos/kareadita/kavita) who has worked with Kavita in reporting security vulnerabilities. If you are interested in
being paid to help secure Kavita, please give them a try. being paid to help secure Kavita, please give them a try.
## PikaPods
If you are looking to try your hand at self-hosting but lack the machine, [PikaPods](https://www.pikapods.com/pods?run=kavita) is a great service that
allows you to easily spin up a server. 20% of app revenues are contributed back to Kavita via OpenCollective.
### License ### License
* [GNU GPL v3](http://www.gnu.org/licenses/gpl.html) * [GNU GPL v3](http://www.gnu.org/licenses/gpl.html)

View File

@ -21,6 +21,7 @@
.content-wrapper { .content-wrapper {
padding: 0 5px 0; padding: 0 5px 0;
overflow: hidden; overflow: hidden;
height: calc(100vh - 56px);
&.closed { &.closed {
overflow: auto; overflow: auto;

View File

@ -1,16 +1,17 @@
<div class="row g-0 mb-2"> <div class="dashboard-card-content">
<h4>Day Breakdown</h4> <div class="row g-0 mb-2">
</div> <h4>Day Breakdown</h4>
</div>
<ngx-charts-bar-vertical <ngx-charts-bar-vertical
class="dark" class="dark"
[view]="view" [results]="dayBreakdown$ | async"
[results]="dayBreakdown$ | async" [xAxis]="true"
[xAxis]="true" [yAxis]="true"
[yAxis]="true" [legend]="showLegend"
[legend]="showLegend" [showXAxisLabel]="true"
[showXAxisLabel]="true" [showYAxisLabel]="true"
[showYAxisLabel]="true" xAxisLabel="Day of Week"
xAxisLabel="Day of Week" yAxisLabel="Reading Events">
yAxisLabel="Reading Events"> </ngx-charts-bar-vertical>
</ngx-charts-bar-vertical> </div>

View File

@ -1,3 +1,11 @@
::ng-deep .dark .ngx-charts text { ::ng-deep .dark .ngx-charts text {
fill: #a0aabe; fill: #a0aabe;
} }
.dashboard-card-content {
width: 100%;
height: 242px;
display: flex;
flex-flow: column;
box-sizing: border-box;
}

View File

@ -16,7 +16,7 @@ export class DayBreakdownComponent implements OnDestroy {
private readonly onDestroy = new Subject<void>(); private readonly onDestroy = new Subject<void>();
view: [number, number] = [700, 400]; view: [number, number] = [0,0];
gradient: boolean = true; gradient: boolean = true;
showLegend: boolean = true; showLegend: boolean = true;
showLabels: boolean = true; showLabels: boolean = true;

View File

@ -1,71 +1,74 @@
<div class="row g-0 mb-2"> <div class="dashboard-card-content">
<div class="col-8"> <div class="row g-0 mb-2">
<h4><span>Format</span> <div class="col-8">
<i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i> <h4><span>Format</span>
</h4> <i class="fa fa-info-circle ms-1" aria-hidden="true" placement="right" [ngbTooltip]="tooltip" role="button" tabindex="0"></i>
</div> </h4>
<div class="col-4"> </div>
<form> <div class="col-4">
<div class="form-check form-switch mt-2"> <form>
<input id="pub-file-breakdown-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch"> <div class="form-check form-switch mt-2">
<label for="pub-file-breakdown-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label> <input id="pub-file-breakdown-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
</div> <label for="pub-file-breakdown-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</form> </div>
</form>
</div>
</div> </div>
<ng-template #tooltip>Not Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings modal.</ng-template>
<ng-container *ngIf="files$ | async as files">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
Extension
</th>
<th scope="col" sortable="format" (sort)="onSort($event)">
Format
</th>
<th scope="col" sortable="totalSize" (sort)="onSort($event)">
Total Size
</th>
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
Total Files
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of files; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.extension || 'Not Classified'}}
</td>
<td>
{{item.format | mangaFormat}}
</td>
<td>
{{item.totalSize | bytes}}
</td>
<td>
{{item.totalFiles | number:'1.0-0'}}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total File Size:</td>
<td></td>
<td></td>
<td>{{((rawData$ | async)?.totalFileSize || 0) | bytes}}</td>
</tr>
</tfoot>
</table>
</ng-template>
</ng-container>
</div> </div>
<ng-template #tooltip>Not Classified means Kavita has not scanned some files. This occurs on old files existing prior to v0.7. You may need to run a forced scan via Library settings modal.</ng-template>
<ng-container *ngIf="files$ | async as files">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart [results]="vizData2$ | async"></ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-striped table-hover table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="extension" (sort)="onSort($event)">
Extension
</th>
<th scope="col" sortable="format" (sort)="onSort($event)">
Format
</th>
<th scope="col" sortable="totalSize" (sort)="onSort($event)">
Total Size
</th>
<th scope="col" sortable="totalFiles" (sort)="onSort($event)">
Total Files
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of files; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.extension || 'Not Classified'}}
</td>
<td>
{{item.format | mangaFormat}}
</td>
<td>
{{item.totalSize | bytes}}
</td>
<td>
{{item.totalFiles | number:'1.0-0'}}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td>Total File Size:</td>
<td></td>
<td></td>
<td>{{((rawData$ | async)?.totalFileSize || 0) | bytes}}</td>
</tr>
</tfoot>
</table>
</ng-template>
</ng-container>

View File

@ -2,3 +2,11 @@
top: unset !important; top: unset !important;
transform: unset !important; transform: unset !important;
} }
.dashboard-card-content {
width: 100%;
height: 242px;
display: flex;
flex-flow: column;
box-sizing: border-box;
}

View File

@ -1,51 +1,54 @@
<div class="row g-0 mb-2"> <div class="dashboard-card-content">
<div class="col-8">
<h4><span>Publication Status</span> <div class="row g-0 mb-2">
</h4> <div class="col-8">
</div> <h4><span>Publication Status</span>
<div class="col-4"> </h4>
<form> </div>
<div class="form-check form-switch mt-2"> <div class="col-4">
<input id="pub-status-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch"> <form>
<label for="pub-status-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label> <div class="form-check form-switch mt-2">
</div> <input id="pub-status-viz" type="checkbox" class="form-check-input" [formControl]="formControl" role="switch">
</form> <label for="pub-status-viz" class="form-check-label">{{formControl.value ? 'Vizualization' : 'Data Table' }}</label>
</div>
</form>
</div>
</div> </div>
<ng-container *ngIf="publicationStatues$ | async as statuses">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart
[results]="statuses"
>
</ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-hover table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
Year
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
Count
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of statuses; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
</ng-container>
</div> </div>
<ng-container *ngIf="publicationStatues$ | async as statuses">
<ng-container *ngIf="formControl.value; else tableLayout">
<ngx-charts-advanced-pie-chart
[results]="statuses"
>
</ngx-charts-advanced-pie-chart>
</ng-container>
<ng-template #tableLayout>
<table class="table table-light table-hover table-striped table-sm scrollable">
<thead>
<tr>
<th scope="col" sortable="name" (sort)="onSort($event)">
Year
</th>
<th scope="col" sortable="value" (sort)="onSort($event)">
Count
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let item of statuses; let idx = index;">
<td id="adhoctask--{{idx}}">
{{item.name}}
</td>
<td>
{{item.value | number:'1.0-0'}}
</td>
</tr>
</tbody>
</table>
</ng-template>
</ng-container>

View File

@ -1,3 +1,11 @@
::ng-deep .pie-label { ::ng-deep .pie-label {
color: var(--body-text-color) !important; color: var(--body-text-color) !important;
} }
.dashboard-card-content {
width: 100%;
height: 242px;
display: flex;
flex-flow: column;
box-sizing: border-box;
}

View File

@ -1,52 +0,0 @@
<ng-container>
<div class="row g-0 mb-2 align-items-center">
<div class="col-4">
<h4>Top Readers</h4>
</div>
<div class="col-8">
<form [formGroup]="formGroup" class="d-inline-flex float-end">
<div class="d-flex me-1" *ngIf="isAdmin">
<label for="time-select-read-by-day" class="form-check-label"></label>
<select id="time-select-read-by-day" class="form-select" formControlName="users"
[class.is-invalid]="formGroup.get('users')?.invalid && formGroup.get('users')?.touched">
<option [value]="0">All Users</option>
<option *ngFor="let item of users$ | async" [value]="item.id">{{item.username}}</option>
</select>
</div>
<div class="d-flex">
<label for="time-select-top-reads" class="form-check-label"></label>
<select id="time-select-top-reads" class="form-select" formControlName="days"
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
<option *ngFor="let item of timePeriods" [value]="item.value">{{item.title}}</option>
</select>
</div>
</form>
</div>
</div>
<div class="row g-0">
<ng-container *ngIf="data$ | async as data">
<ngx-charts-line-chart
*ngIf="data.length > 0; else noData"
class="dark"
[legend]="true"
legendTitle="Formats"
[showXAxisLabel]="true"
[showYAxisLabel]="true"
[xAxis]="true"
[yAxis]="true"
[showGridLines]="false"
[showRefLines]="true"
[roundDomains]="true"
[autoScale]="true"
xAxisLabel="Time"
yAxisLabel="Reading Activity"
[timeline]="false"
[results]="data"
>
</ngx-charts-line-chart>
</ng-container>
</div>
<ng-template #noData>
No Reading progress
</ng-template>
</ng-container>

View File

@ -0,0 +1,54 @@
<ng-container>
<div class="dashboard-card-content">
<div class="row g-0 mb-2 align-items-center">
<div class="col-4">
<h4>Top Readers</h4>
</div>
<div class="col-8">
<form [formGroup]="formGroup" class="d-inline-flex float-end">
<div class="d-flex me-1" *ngIf="isAdmin && !individualUserMode">
<label for="time-select-read-by-day" class="form-check-label"></label>
<select id="time-select-read-by-day" class="form-select" formControlName="users"
[class.is-invalid]="formGroup.get('users')?.invalid && formGroup.get('users')?.touched">
<option [value]="0">All Users</option>
<option *ngFor="let item of users$ | async" [value]="item.id">{{item.username}}</option>
</select>
</div>
<div class="d-flex">
<label for="time-select-top-reads" class="form-check-label"></label>
<select id="time-select-top-reads" class="form-select" formControlName="days"
[class.is-invalid]="formGroup.get('days')?.invalid && formGroup.get('days')?.touched">
<option *ngFor="let item of timePeriods" [value]="item.value">{{item.title}}</option>
</select>
</div>
</form>
</div>
</div>
<div class="row g-0">
<ng-container *ngIf="data$ | async as data">
<ngx-charts-line-chart
*ngIf="data.length > 0; else noData"
class="dark"
[legend]="true"
legendTitle="Formats"
[showXAxisLabel]="true"
[showYAxisLabel]="true"
[xAxis]="true"
[yAxis]="true"
[showGridLines]="false"
[showRefLines]="true"
[roundDomains]="true"
[autoScale]="true"
xAxisLabel="Time"
yAxisLabel="Reading Activity"
[timeline]="false"
[results]="data"
>
</ngx-charts-line-chart>
</ng-container>
</div>
<ng-template #noData>
No Reading progress
</ng-template>
</div>
</ng-container>

View File

@ -12,17 +12,18 @@ const options: Intl.DateTimeFormatOptions = { month: "short", day: "numeric" };
const mangaFormatPipe = new MangaFormatPipe(); const mangaFormatPipe = new MangaFormatPipe();
@Component({ @Component({
selector: 'app-read-by-day-and', selector: 'app-reading-activity',
templateUrl: './read-by-day-and.component.html', templateUrl: './reading-activity.component.html',
styleUrls: ['./read-by-day-and.component.scss'], styleUrls: ['./reading-activity.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class ReadByDayAndComponent implements OnInit, OnDestroy { export class ReadingActivityComponent implements OnInit, OnDestroy {
/** /**
* Only show for one user * Only show for one user
*/ */
@Input() userId: number = 0; @Input() userId: number = 0;
@Input() isAdmin: boolean = true; @Input() isAdmin: boolean = true;
@Input() individualUserMode: boolean = false;
view: [number, number] = [0, 400]; view: [number, number] = [0, 400];
formGroup: FormGroup = new FormGroup({ formGroup: FormGroup = new FormGroup({

View File

@ -92,26 +92,26 @@
</div> </div>
<ng-container *ngIf="breakpoint$ | async as bp"> <ng-container *ngIf="breakpoint$ | async as bp">
<div class="row g-0 pt-2 pb-2" *ngIf="bp > Breakpoint.Mobile"> <div class="row g-0 pt-2 pb-2">
<app-top-readers></app-top-readers> <app-top-readers></app-top-readers>
</div> </div>
<div class="row g-0 pt-4 pb-2" style="height: 242px" *ngIf="bp > Breakpoint.Mobile"> <div class="row g-0 pt-4 pb-2">
<div class="col-md-6 col-sm-12"> <div class="col-lg-6 col-md-12 mb-md-5">
<app-file-breakdown-stats></app-file-breakdown-stats> <app-file-breakdown-stats></app-file-breakdown-stats>
</div> </div>
<div class="col-md-6 col-sm-12"> <div class="col-lg-6 col-md-12">
<app-publication-status-stats></app-publication-status-stats> <app-publication-status-stats></app-publication-status-stats>
</div> </div>
</div> </div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px" *ngIf="bp > Breakpoint.Mobile"> <div class="row g-0 pt-4 pb-2">
<div class="col-md-12 col-sm-12 mt-4 pt-2"> <div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-read-by-day-and [isAdmin]="true"></app-read-by-day-and> <app-reading-activity [isAdmin]="true"></app-reading-activity>
</div> </div>
</div> </div>
<div class="row g-0 pt-4 pb-2 " style="height: 242px" *ngIf="bp > Breakpoint.Mobile"> <div class="row g-0 pt-4 pb-2">
<div class="col-md-12 col-sm-12 mt-4 pt-2"> <div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-day-breakdown></app-day-breakdown> <app-day-breakdown></app-day-breakdown>
</div> </div>

View File

@ -8,3 +8,4 @@
overflow-x: hidden; overflow-x: hidden;
align-items: start; align-items: start;
} }

View File

@ -9,7 +9,7 @@
<div class="row g-0 pt-4 pb-2 " style="height: 242px"> <div class="row g-0 pt-4 pb-2 " style="height: 242px">
<div class="col-md-12 col-sm-12 mt-4 pt-2"> <div class="col-md-12 col-sm-12 mt-4 pt-2">
<app-read-by-day-and [userId]="userId" [isAdmin]="(isAdmin$ | async) || false"></app-read-by-day-and> <app-reading-activity [userId]="userId" [isAdmin]="(isAdmin$ | async) || false" [individualUserMode]="true"></app-reading-activity>
</div> </div>
</div> </div>

View File

@ -14,7 +14,7 @@ import { MangaFormatStatsComponent } from './_components/manga-format-stats/mang
import { FileBreakdownStatsComponent } from './_components/file-breakdown-stats/file-breakdown-stats.component'; import { FileBreakdownStatsComponent } from './_components/file-breakdown-stats/file-breakdown-stats.component';
import { PipeModule } from '../pipe/pipe.module'; import { PipeModule } from '../pipe/pipe.module';
import { TopReadersComponent } from './_components/top-readers/top-readers.component'; import { TopReadersComponent } from './_components/top-readers/top-readers.component';
import { ReadByDayAndComponent } from './_components/read-by-day-and/read-by-day-and.component'; import { ReadingActivityComponent } from './_components/reading-activity/reading-activity.component';
import { GenericListModalComponent } from './_components/_modals/generic-list-modal/generic-list-modal.component'; import { GenericListModalComponent } from './_components/_modals/generic-list-modal/generic-list-modal.component';
import { DayBreakdownComponent } from './_components/day-breakdown/day-breakdown.component'; import { DayBreakdownComponent } from './_components/day-breakdown/day-breakdown.component';
import { DayOfWeekPipe } from './_pipes/day-of-week.pipe'; import { DayOfWeekPipe } from './_pipes/day-of-week.pipe';
@ -31,7 +31,7 @@ import { DayOfWeekPipe } from './_pipes/day-of-week.pipe';
MangaFormatStatsComponent, MangaFormatStatsComponent,
FileBreakdownStatsComponent, FileBreakdownStatsComponent,
TopReadersComponent, TopReadersComponent,
ReadByDayAndComponent, ReadingActivityComponent,
GenericListModalComponent, GenericListModalComponent,
DayBreakdownComponent, DayBreakdownComponent,
DayOfWeekPipe DayOfWeekPipe