phpoffice/phpspreadsheet - How to exclude no value cells from getHighestRow() - php

$eanStyle = new \PHPExcel_Style();
$eanStyle->getNumberFormat()->applyFromArray([
'code' => '0000000000000'
]);
/* apply styles */
$mainSheet->duplicateStyle($eanStyle, 'A2:A10000');
Code above generates .xlsx template file, user enters data (7 rows) and upload file and then:
$mainSheet->getHighestRow('A'); // retruns 10000 instead of 8 (7 rows + header)
Thanks in advance for help.

I would advise you create a read filter to read only specific rows and columns. This would prevent the other empty rows being included:
$inputFileType = 'Xls';
$inputFileName = './sampleData/example1.xls';
$sheetname = 'Data Sheet #3';
/** Define a Read Filter class implementing \PhpOffice\PhpSpreadsheet\Reader\IReadFilter */
class MyReadFilter implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter {
public function readCell($column, $row, $worksheetName = '') {
// Read rows 1 to 7 and columns A to E only
if ($row >= 1 && $row <= 7) {
if (in_array($column,range('A','E'))) {
return true;
}
}
return false;
}
}
/** Create an Instance of our Read Filter **/
$filterSubset = new MyReadFilter();
/** Create a new Reader of the type defined in $inputFileType **/
$reader = \PhpOffice\PhpSpreadsheet\IOFactory::createReader($inputFileType);
/** Tell the Reader that we want to use the Read Filter **/
$reader->setReadFilter($filterSubset);
/** Load only the rows and columns that match our filter to Spreadsheet **/
$spreadsheet = $reader->load($inputFileName);

Related

PhpWord output doesn't work in LibreOffice but works fine in MS Word

This relates to a semi-known issue listed on the GitHub page [link], where the way PhpWord generates tables causes it to set the cell width to "", which isn't valid in LibreOffice, but is fine in Word.
The issue on GitHub lists html-to-word conversion specifically, but I'm getting this issue when just going by the regular object-oriented interface also.
I've tried manually setting the cell width, but it doesn't seem to do anything.
I'm asking here to see if anyone has any workaround to this issue, as it makes it nearly impossible to debug my output on a Linux box without access to MS Word. I need to export .docx for a client.
Why not just export to .odt to test it instead? If I do that it won't render the list I have on the page either.....
Here's a rough outline of the document I'm working with (I'm not at liberty to share the exact code due to NDAs):
$document = new PhpWord\PhpWord();
$section = $document->addSection();
// This list doesn't get rendered in the ODT file
$section->addListItem('Employee ID: 98765');
$section->addListItem('First Name: John');
$section->addListItem('Last Name: Doe');
$section->addListItem('SSN: 12345');
// This table has errors in LibreOffice when exported to .docx
if ($show_adv_details) {
$table = $section->addTable();
$table->addRow();
$table->addCell()->addText('SSN');
$table->addCell()->addText('Email');
$table->addCell()->addText('Phone No');
$table->addRow();
$table->addCell()->addText('12345');
$table->addCell()->addText('jd#example.com');
$table->addCell()->addText('999 1234');
// repeat ad nauseam
}
$section->addTitle('Comment');
$section->addTitle('Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.');
I'm using PHP 7.0 (ancient legacy system, can't do anything about it) on Ubuntu.
So just to reiterate the question here at the end:
Is there something I can do to make this document render correctly on all the different outputs? The documentation doesn't seem to be much help.
Well, I'm not a maintainer from the PHPOffice package but here is a workaround which I made digging in the package code and fixing your two issues:
ODT List does not work;
Tables on Word2007 Writer when opening with LibreOffice does not work.
I realized that the ListItem.php class did not exist on ODText writer, and I suppose that this is why you can not add lists to .odt files.
You need to add the widths manually to columns to make it work on LibreOffice (as Nigel said, you can go through each addCell() call and add some number, even "1" works).
How to fix both issues
We will "override" the original files with some updates that fix the issues.
In order to make it work, you should have installed PHPWord using Composer.
(This is the only option available on the installation guide, but there are other ways to do that).
Without Composer, require the custom files after PHPWord require (I think that it works). Or change directly the files on their locations (a bad idea).
Files
Create a structure like that:
Custom
├── Element
│   └── Table.php
└── Writer
└── ODText
└── Element
└── ListItem.php
Of course, you can use any other, but we're going to "override" the original package files, so I kept its structure.
We will use Autoload Files to get those files:
composer.json:
...
"autoload": {
"files": [
"Custom/Element/Table.php",
"Custom/Writer/ODText/Element/ListItem.php"
]
},
...
If there's no "autoload" key on your composer.json, you have to add it, and the same goes for the "files" key.
Run composer dump-autoload to update the changes.
Custom classes
Now, we have to add the code to our custom files.
Custom/Writer/ODText/Element/ListItem.php:
<?php
namespace PhpOffice\PhpWord\Writer\ODText\Element;
/**
* ListItem element writer
*/
class ListItem extends AbstractElement
{
/**
* Write list item element.
*/
public function write()
{
$xmlWriter = $this->getXmlWriter();
$element = $this->getElement();
if (!$element instanceof \PhpOffice\PhpWord\Element\ListItem) {
return;
}
$textObject = $element->getTextObject();
$xmlWriter->startElement('text:list');
$xmlWriter->writeAttribute('text:style-name', 'L1');
$xmlWriter->startElement('text:list-item');
$xmlWriter->startElement('text:p');
$xmlWriter->writeAttribute('text:style-name', 'P1');
$elementWriter = new Text($xmlWriter, $textObject, true);
$elementWriter->write();
$xmlWriter->endElement(); // text:list
$xmlWriter->endElement(); // text:p
$xmlWriter->endElement(); // text:list-item
}
}
The file has been adapted from the Word2007 version and fixes your first issue. Now lists will work on ODText.
Custom/Element/Table.php:
<?php
namespace PhpOffice\PhpWord\Element;
use PhpOffice\PhpWord\Style\Table as TableStyle;
/**
* Table element writer
*/
class Table extends AbstractElement
{
/**
* Table style
*
* #var \PhpOffice\PhpWord\Style\Table
*/
private $style;
/**
* Table rows
*
* #var \PhpOffice\PhpWord\Element\Row[]
*/
private $rows = array();
/**
* Table width
*
* #var int
*/
private $width = null;
/**
* Create a new table
*
* #param mixed $style
*/
public function __construct($style = null)
{
$this->style = $this->setNewStyle(new TableStyle(), $style);
}
/**
* Add a row
*
* #param int $height
* #param mixed $style
* #return \PhpOffice\PhpWord\Element\Row
*/
public function addRow($height = null, $style = null)
{
$row = new Row($height, $style);
$row->setParentContainer($this);
$this->rows[] = $row;
return $row;
}
/**
* Add a cell
*
* #param int $width
* #param mixed $style
* #return \PhpOffice\PhpWord\Element\Cell
*/
public function addCell($width = 1, $style = null)
{
$index = count($this->rows) - 1;
$row = $this->rows[$index];
$cell = $row->addCell($width, $style);
return $cell;
}
/**
* Get all rows
*
* #return \PhpOffice\PhpWord\Element\Row[]
*/
public function getRows()
{
return $this->rows;
}
/**
* Get table style
*
* #return \PhpOffice\PhpWord\Style\Table
*/
public function getStyle()
{
return $this->style;
}
/**
* Get table width
*
* #return int
*/
public function getWidth()
{
return $this->width;
}
/**
* Set table width.
*
* #param int $width
*/
public function setWidth($width)
{
$this->width = $width;
}
/**
* Get column count
*
* #return int
*/
public function countColumns()
{
$columnCount = 0;
$rowCount = count($this->rows);
for ($i = 0; $i < $rowCount; $i++) {
/** #var \PhpOffice\PhpWord\Element\Row $row Type hint */
$row = $this->rows[$i];
$cellCount = count($row->getCells());
if ($columnCount < $cellCount) {
$columnCount = $cellCount;
}
}
return $columnCount;
}
/**
* The first declared cell width for each column
*
* #return int[]
*/
public function findFirstDefinedCellWidths()
{
$cellWidths = array();
foreach ($this->rows as $row) {
$cells = $row->getCells();
if (count($cells) <= count($cellWidths)) {
continue;
}
$cellWidths = array();
foreach ($cells as $cell) {
$cellWidths[] = $cell->getWidth();
}
}
return $cellWidths;
}
}
As we are "overwriting" the file, we cannot reuse the original file extending it (at least, I don't know how). The only change here is on public function addCell($width = 1, $style = null) that makes always "1" as default value to the function. This fixes your second issue, and now you can call $table->addCell() without a value.
DEMO
require __DIR__ . '/vendor/autoload.php';
$document = new \PhpOffice\PhpWord\PhpWord();
$section = $document->addSection();
// This list **does** get rendered in the ODT file
$section->addListItem('Employee ID: 98765');
$section->addListItem('First Name: John');
$section->addListItem('Last Name: Doe');
$section->addListItem('SSN: 12345');
$table = $section->addTable();
$table->addRow();
$table->addCell()->addText('SSN');
$table->addCell()->addText('Email');
$table->addCell()->addText('Phone No');
$table->addRow();
$table->addCell()->addText('12345');
$table->addCell()->addText('jd#example.com');
$table->addCell()->addText('999 1234');
// Save the document as DOCX
$objWriter = \PhpOffice\PhpWord\IOFactory::createWriter($document, 'Word2007');
$objWriter->save('document.docx');
// Save the document as ODT
$objWriter = \PhpOffice\PhpWord\IOFactory::createWriter($document, 'ODText');
$objWriter->save('document.odt');

Laravel excel get total number of rows before import

Straight forward question. How does one get the total number of rows in a spreadsheet with laravel-excel?
I now have a working counter of how many rows have been processed (in the CompanyImport file), but I need the total number of rows before I start adding the rows to the database.
The sheet I'm importing is almost 1M rows, so I am trying to create a progress bar.
My import:
public function model(array $row)
{
# Counter
++$this->currentRow;
# Dont create or validate on empty rows
# Bad workaround
# TODO: better solution
if (!array_filter($row)) {
return null;
}
# Create company
$company = new Company;
$company->crn = $row['crn'];
$company->name = $row['name'];
$company->email = $row['email'];
$company->phone = $row['phone'];
$company->website = (!empty($row['website'])) ? Helper::addScheme($row['website']) : '';
$company->save();
# Everything empty.. delete address
if (!empty($row['country']) || !empty($row['state']) || !empty($row['postal']) || !empty($row['address']) || !empty($row['zip'])) {
# Create address
$address = new CompanyAddress;
$address->company_id = $company->id;
$address->country = $row['country'];
$address->state = $row['state'];
$address->postal = $row['postal'];
$address->address = $row['address'];
$address->zip = $row['zip'];
$address->save();
# Attach
$company->addresses()->save($address);
}
# Update session counter
Session::put('importCurrentRow', $this->currentRow);
return $company;
}
My controller:
public function postImport(Import $request)
{
# Import
$import = new CompaniesImport;
# Todo
# Total number of rows in the sheet to session
Session::put('importTotalRows');
#
Excel::import($import, $request->file('file')->getPathname());
return response()->json([
'success' => true
]);
}
In Laravel Excel 3.1 you can get the total rows by implementing WithEvents and listening to beforeImport event.
<?php
namespace App\Imports;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithEvents;
use Maatwebsite\Excel\Events\BeforeImport;
class UserImport extends ToModel, WithEvents {
[...]
public function registerEvents(): array
{
return [
BeforeImport::class => function (BeforeImport $event) {
$totalRows = $event->getReader()->getTotalRows();
if (!empty($totalRows)) {
echo $totalRows['Worksheet'];
}
}
];
}
[...]
}
You can use below code to calculate number of rows
Excel::import($import, 'users.xlsx');
dd('Row count: ' . $import->getRowCount());
You can check the Docs
Update
The above method was for calculating the rows which have been imported so far.
In order to get number of rows which are in the sheet, you need to use getHighestRow
Excel::load($file, function($reader) {
$lastrow = $reader->getActiveSheet()->getHighestRow();
dd($lastrow);
});
This has been referenced here by author of the Plugin.
1.- Make file for import
php artisan make:import ImportableImport
2.- Your File Import
<?php
namespace App\Imports;
use Illuminate\Support\Collection;
use Maatwebsite\Excel\Concerns\ToCollection;
use Maatwebsite\Excel\Concerns\Importable;
class ImportablesImport implements ToCollection
{
use Importable;
/**
* #param Collection $collection
*/
public function collection(Collection $collection)
{
//
}
}
3.- Your controller
$array = (new ImportablesImport)->toArray($file);
dd(count($array[0]));
This doc: https://docs.laravel-excel.com/3.1/imports/importables.html
You can use below code to get number of rows before import
$fileExtension = pathinfo($file, PATHINFO_EXTENSION);
$temporaryFileFactory=new \Maatwebsite\Excel\Files\TemporaryFileFactory(
config('excel.temporary_files.local_path',
config('excel.exports.temp_path',
storage_path('framework/laravel-excel'))
),
config('excel.temporary_files.remote_disk')
);
$temporaryFile = $temporaryFileFactory->make($fileExtension);
$currentFile = $temporaryFile->copyFrom($file,null);
$reader = \Maatwebsite\Excel\Factories\ReaderFactory::make(null,$currentFile);
$info = $reader->listWorksheetInfo($currentFile->getLocalPath());
$totalRows = 0;
foreach ($info as $sheet) {
$totalRows+= $sheet['totalRows'];
}
$currentFile->delete();
The code taken from Laravel Excel libary
Check Below Example:
$sheet->getActiveSheet()->getStyle('A2:A' . $sheet->getHighestRow())->getFont()->setBold(true);
by using getHighestRow() method you can fetch the total number of rows. In the above code sample I've applied font as BOLD to the second cell of first column till the maximum row count of that same first column.
Detailed Code Snippet of another example:
$excel->sheet('Employee Details', function ($sheet) use ($AllData) {
$sheet->fromArray($AllData);
$sheet->setAutoSize(true);
$sheet->getStyle('A2:A' . $sheet->getHighestRow())->applyFromArray(array('alignment' => array('horizontal' => \PHPExcel_Style_Alignment::HORIZONTAL_LEFT)));
});

PHPExcel ReadFilter doesnt work

I'm still stuck with symfony2 and phpexcel..
First I don't know why my HTML writer doesn't generate <thead></thead> tags..
I don't understand why blanks columns still displaying as you can see on this screenshot, a ReadFilter is applied:
I just want to load the first 13 columns:
public function showClientAction()
{
$excel = glob(''.path.'\\'.tofile.'\\'.$file.'.{xlsx,xls,xlsm,xlsm.ink}', GLOB_BRACE);
$filterSubset = new \PHPExcel_Reader_DefaultReadFilter('A','N');
$objReader = \PHPExcel_IOFactory::createReaderForFile($excel[0]);
$objReader->setReadFilter($filterSubset);
/** Read the list of worksheet names and select the one that we want to load **/
$worksheetList = $objReader->listWorksheetNames($excel[0]);
$sheetname = $worksheetList[0];
/** Advise the Reader of which WorkSheets we want to load **/
$objReader->setLoadSheetsOnly($sheetname);
$objPHPExcel = $objReader->load($excel[0]);
$writer = \PHPExcel_IOFactory::createWriter($objPHPExcel, "HTML");
$writer->generateSheetData();
$writer->generateStyles();
return $this->render('SocietyPerfclientBundle:Default:testexcel.html.twig', array(
'excelHtml'=>$writer
));
}
My Filter :
class PHPExcel_Reader_DefaultReadFilter implements PHPExcel_Reader_IReadFilter {
public function __construct($fromColumn, $toColumn) {
$this->columns = array();
$toColumn++;
while ($fromColumn !== $toColumn) {
$this->columns[] = $fromColumn++;
}
}
public function readCell($column, $row, $worksheetName = '') {
// Read columns from 'A' to 'AF'
if (in_array($column, $this->columns)) {
return true;
}
return false;
}
}
I can't understand why these blank column are here...
ReadFilter is not working with HTML Writer ?
I can't just do :
.column14 { display:none;!important;}
.column15 { display:none;!important;}
.column16 { display:none;!important;}
.column17 { display:none;!important;}
etc...
Because I use jQuery Plugin "floatThead" to create a fixed <thead></thead>
In my view :
var table = $('#sheet0'); // select the table of interest
var thead = $('<thead/>').prependTo(table);
// create <thead></thead>
table.find('tbody tr.row0').appendTo(thead);
// Now the table is ready to have your chosen method applied to fix the position of the thead.
$('table.sheet0').floatThead({
position: 'fixed',
index: '8',
overflow: 'auto'
});
Please help me..
i think :
$this->columns[] = $fromColumn++;
do nothing .
try :
for ($i = $fromColumn; $i != $toColumns ; $i++)
{
$this->columns[$i]=$i;
}

PHPExcel setWidth doesnt work

i convert an excel file to HTML Table with PHPExcel
(PHPspreadsheet) with Symfony 2.5
I'm trying to set a filter to only load the range ('A','N') , the first 13 columns. not working..
I'm also trying to set the Width of the 'N' Column. not working..
when i dump the column's width value is correct..
I can increase the columns width but not decrease them..
it looks like the text inside the cell is defining the cell's width automatically..
Here is my controller :
public function showClientAction($client)
{
$excel = glob(''.path.'\\'.path.'\\filename_' .$client.'.{xlsx,xls,xlsm,xlsm.ink}', GLOB_BRACE);
$filterSubset = new \PHPExcel_Reader_DefaultReadFilter(1,1000,range('A','N'));
$objReader = \PHPExcel_IOFactory::createReaderForFile($excel[0]);
$objReader->setReadFilter($filterSubset);
/** Read the list of worksheet names and select the one that we want to load **/
$worksheetList = $objReader->listWorksheetNames($excel[0]);
$sheetname = $worksheetList[0];
/** Advise the Reader of which WorkSheets we want to load **/
$objReader->setLoadSheetsOnly($sheetname);
$objPHPExcel = $objReader->load($excel[0]);
$objPHPExcel->getActiveSheet()->getColumnDimensionByColumn('13')->setAutoSize(false);
$objPHPExcel->getActiveSheet()->getColumnDimensionByColumn('13')->setWidth(2.5);
// OUTPUT is : int (13) applied correctly
var_dump($objPHPExcel->getActiveSheet()->getColumnDimensionByColumn('13'));
$writer = \PHPExcel_IOFactory::createWriter($objPHPExcel, "HTML");
$writer->generateSheetData();
$writer->generateStyles();
return $this->render('SocPerfclientBundle:Default:testexcel.html.twig', array(
'excelHtml'=>$writer,
'stylesExcel'=>$writer,
'client'=>$nom_client
));
}
My filter :
class PHPExcel_Reader_DefaultReadFilter implements PHPExcel_Reader_IReadFilter
{
public $_startRow = 0;
public $_endRow = 0;
public $_columns = array();
/** Get the list of rows and columns to read */
public function __construct($startRow, $endRow, $columns) {
$this->_startRow = $startRow;
$this->_endRow = $endRow;
$this->_columns = $columns;
}
public function readCell($column, $row, $worksheetName = '') {
// Only read the rows and columns that were configured
if ($row >= $this->_startRow && $row <= $this->_endRow) {
if (in_array($column,$this->_columns)) {
return true;
}
}
return false;
}
}
my view :
{{ excelHtml.generateSheetData | raw }}
{{ stylesExcel.generateStyles | raw }}
Here a screenshot html view :
We can see the "RCA" column still having the initial width.. my setWidth isnt applied..
if i change the link by a shorter word like : yes.docx , the column decreases.

box/spout - freeze 1st row (pane) of spreadsheet

Is it possible to freeze 1st row (freeze pane) of the spreadsheet using box/spout?
With PHPexcel I do like that:
$objPHPExcel=new PHPExcel();
$ActiveSheet=$objPHPExcel->getActiveSheet();
$ActiveSheet->freezePane('A2');
Cannot use PHPexcel, as I am working with big files.
Found a hack to add this feature.
Inside of Spout\Writer\XLSX\Manager\WorksheetManager.php : function startSheet
After this line
fwrite($sheetFilePointer, self::SHEET_XML_FILE_HEADER);
Add this line
fwrite($sheetFilePointer, '<sheetViews><sheetView showRowColHeaders="1" showGridLines="true" workbookViewId="0" tabSelected="1">'
.'<pane state="frozen" activePane="bottomLeft" topLeftCell="A2" ySplit="1"/>'
.'<selection sqref="A1" activeCell="A1" pane="bottomLeft"/></sheetView></sheetViews>');
<?php
/**
*
* Inspired by: https://github.com/box/spout/issues/368
*
* Simple helper class for Spout - to return rows indexed by the header in the sheet
*
* Author: Jaspal Singh - https://github.com/jaspal747
* Feel free to make any edits as needed. Cheers!
*
*/
class SpoutHelper {
private $rawHeadersArray = []; //Local array to hold the Raw Headers for performance
private $formattedHeadersArray = []; //Local array to hold the Formatted Headers for performance
private $headerRowNumber; //Row number where the header col is located in the file
/**
* Initialize on a per sheet basis
* Allow users to mention which row number contains the headers
*/
public function __construct($sheet, $headerRowNumber = 1) {
$this->flushHeaders();
$this->headerRowNumber = $headerRowNumber;
$this->getFormattedHeaders($sheet);//Since this also calls the getRawHeaders, we will have both the arrays set at once
}
/**
*
* Set the rawHeadersArray by getting the raw headers from the headerRowNumber or the 1st row
* Once done, set them to a local variable for being reused later
*
*/
public function getRawHeaders($sheet) {
if (empty($this->rawHeadersArray)) {
/**
* first get column headers
*/
foreach ($sheet->getRowIterator() as $key => $row) {
if ($key == $this->headerRowNumber) {
/**
* iterate once to get the column headers
*/
$this->rawHeadersArray = $row->toArray();
break;
}
}
} else {
/**
* From local cache
*/
}
return $this->rawHeadersArray;
}
/**
*
* Set the formattedHeadersArray by getting the raw headers and the parsing them
* Once done, set them to a local variable for being reused later
*
*/
public function getFormattedHeaders($sheet) {
if (empty($this->formattedHeadersArray)) {
$this->formattedHeadersArray = $this->getRawHeaders($sheet);
/**
* Now format them
*/
foreach ($this->formattedHeadersArray as $key => $value) {
if (is_a($value, 'DateTime')) { //Somehow instanceOf does not work well with DateTime, hence using is_a -- ?
$this->formattedHeadersArray[$key] = $value->format('Y-m-d');//Since the dates in headers are avilable as DateTime Objects
} else {
$this->formattedHeadersArray[$key] = strtolower(str_replace(' ' , '_', trim($value)));
}
/**
* Add more rules here as needed
*/
}
} else {
/**
* Return from local cache
*/
}
return $this->formattedHeadersArray;
}
/**
* Return row with Raw Headers
*/
public function rowWithRawHeaders($rowArray) {
return $this->returnRowWithHeaderAsKeys($this->rawHeadersArray, $rowArray);
}
/**
* Return row with Formatted Headers
*/
public function rowWithFormattedHeaders($rowArray) {
return $this->returnRowWithHeaderAsKeys($this->formattedHeadersArray, $rowArray);
}
/**
* Set the headers to keys and row as values
*/
private function returnRowWithHeaderAsKeys($headers, $rowArray) {
$headerColCount = count($headers);
$rowColCount = count($rowArray);
$colCountDiff = $headerColCount - $rowColCount;
if ($colCountDiff > 0) {
//Pad the rowArray with empty values
$rowArray = array_pad($rowArray, $headerColCount, '');
}
return array_combine($headers, $rowArray);
}
/**
* Flush local caches before each sheet
*/
public function flushHeaders() {
$this->formattedHeadersArray = [];
$this->rawHeadersArray = [];
}
}
And then in your main file you can do:
$reader = ReaderEntityFactory::createReaderFromFile($filePath);
$reader->open($filePath);
/**
* Now get the data
*/
foreach ($reader->getSheetIterator() as $sheet) {
$spoutHelper = new SpoutHelper($sheet, 1); //Initialize SpoutHelper with the current Sheet and the row number which contains the header
foreach ($sheet->getRowIterator() as $key => $row) {
if ($key == 1) {
//echo "Skipping Headers row";
continue;
}
//Get the indexed array with col name as key and col val as value`
$rowWithHeaderKeys = $spoutHelper->rowWithFormattedHeaders($row->toArray());
}
}
Source: https://gist.github.com/jaspal747/2bd515f9e318b0331f3ca3d2297742c5
It's not possible to freeze panes with Spout yet. But you can always fork the repo and implement this feature :)

Categories