mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-27 21:03:54 +01:00
Extracted and tested the database interactions from Category
This commit is contained in:
parent
d96acd8c87
commit
1ba8e93346
7 changed files with 515 additions and 232 deletions
|
|
@ -22,7 +22,7 @@ import dagger.android.AndroidInjector;
|
|||
import dagger.android.DaggerApplication;
|
||||
import fr.free.nrw.commons.auth.SessionManager;
|
||||
import fr.free.nrw.commons.contributions.ContributionDao;
|
||||
import fr.free.nrw.commons.data.Category;
|
||||
import fr.free.nrw.commons.data.CategoryDao;
|
||||
import fr.free.nrw.commons.data.DBOpenHelper;
|
||||
import fr.free.nrw.commons.di.CommonsApplicationComponent;
|
||||
import fr.free.nrw.commons.di.CommonsApplicationModule;
|
||||
|
|
@ -49,15 +49,15 @@ public class CommonsApplication extends DaggerApplication {
|
|||
@Inject @Named("default_preferences") SharedPreferences defaultPrefs;
|
||||
@Inject @Named("application_preferences") SharedPreferences applicationPrefs;
|
||||
@Inject @Named("prefs") SharedPreferences otherPrefs;
|
||||
|
||||
|
||||
public static final String DEFAULT_EDIT_SUMMARY = "Uploaded using Android Commons app";
|
||||
|
||||
|
||||
public static final String FEEDBACK_EMAIL = "commons-app-android@googlegroups.com";
|
||||
|
||||
|
||||
public static final String LOGS_PRIVATE_EMAIL = "commons-app-android-private@googlegroups.com";
|
||||
|
||||
|
||||
public static final String FEEDBACK_EMAIL_SUBJECT = "Commons Android App (%s) Feedback";
|
||||
|
||||
|
||||
private CommonsApplicationComponent component;
|
||||
private RefWatcher refWatcher;
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ public class CommonsApplication extends DaggerApplication {
|
|||
}
|
||||
return LeakCanary.install(this);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Provides a way to get member refWatcher
|
||||
*
|
||||
|
|
@ -106,7 +106,7 @@ public class CommonsApplication extends DaggerApplication {
|
|||
CommonsApplication application = (CommonsApplication) context.getApplicationContext();
|
||||
return application.refWatcher;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Helps in injecting dependency library Dagger
|
||||
* @return Dagger injector
|
||||
|
|
@ -169,7 +169,7 @@ public class CommonsApplication extends DaggerApplication {
|
|||
SQLiteDatabase db = dbOpenHelper.getWritableDatabase();
|
||||
|
||||
ModifierSequence.Table.onDelete(db);
|
||||
Category.Table.onDelete(db);
|
||||
CategoryDao.Table.onDelete(db);
|
||||
ContributionDao.Table.onDelete(db);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import butterknife.ButterKnife;
|
|||
import dagger.android.support.DaggerFragment;
|
||||
import fr.free.nrw.commons.R;
|
||||
import fr.free.nrw.commons.data.Category;
|
||||
import fr.free.nrw.commons.data.CategoryDao;
|
||||
import fr.free.nrw.commons.mwapi.MediaWikiApi;
|
||||
import fr.free.nrw.commons.upload.MwVolleyApi;
|
||||
import fr.free.nrw.commons.utils.StringSortingUtils;
|
||||
|
|
@ -79,7 +80,7 @@ public class CategorizationFragment extends DaggerFragment {
|
|||
private final CategoriesAdapterFactory adapterFactory = new CategoriesAdapterFactory(item -> {
|
||||
if (item.isSelected()) {
|
||||
selectedCategories.add(item);
|
||||
updateCategoryCount(item, databaseClient);
|
||||
updateCategoryCount(item);
|
||||
} else {
|
||||
selectedCategories.remove(item);
|
||||
}
|
||||
|
|
@ -261,7 +262,7 @@ public class CategorizationFragment extends DaggerFragment {
|
|||
}
|
||||
|
||||
private Observable<CategoryItem> recentCategories() {
|
||||
return Observable.fromIterable(Category.recentCategories(databaseClient, SEARCH_CATS_LIMIT))
|
||||
return Observable.fromIterable(new CategoryDao(databaseClient).recentCategories(SEARCH_CATS_LIMIT))
|
||||
.map(s -> new CategoryItem(s, false));
|
||||
}
|
||||
|
||||
|
|
@ -311,24 +312,17 @@ public class CategorizationFragment extends DaggerFragment {
|
|||
|| item.matches("(.*)needing(.*)") || item.matches("(.*)taken on(.*)"));
|
||||
}
|
||||
|
||||
private void updateCategoryCount(CategoryItem item, ContentProviderClient client) {
|
||||
Category cat = lookupCategory(item.getName());
|
||||
cat.incTimesUsed();
|
||||
cat.save(client);
|
||||
}
|
||||
private void updateCategoryCount(CategoryItem item) {
|
||||
CategoryDao categoryDao = new CategoryDao(databaseClient);
|
||||
Category category = categoryDao.find(item.getName());
|
||||
|
||||
private Category lookupCategory(String name) {
|
||||
Category cat = Category.find(databaseClient, name);
|
||||
|
||||
if (cat == null) {
|
||||
// Newly used category...
|
||||
cat = new Category();
|
||||
cat.setName(name);
|
||||
cat.setLastUsed(new Date());
|
||||
cat.setTimesUsed(0);
|
||||
// Newly used category...
|
||||
if (category == null) {
|
||||
category = new Category(null, item.getName(), new Date(), 0);
|
||||
}
|
||||
|
||||
return cat;
|
||||
category.incTimesUsed();
|
||||
categoryDao.save(category);
|
||||
}
|
||||
|
||||
public int getCurrentSelectedCount() {
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ import fr.free.nrw.commons.data.DBOpenHelper;
|
|||
import timber.log.Timber;
|
||||
|
||||
import static android.content.UriMatcher.NO_MATCH;
|
||||
import static fr.free.nrw.commons.data.Category.Table.ALL_FIELDS;
|
||||
import static fr.free.nrw.commons.data.Category.Table.COLUMN_ID;
|
||||
import static fr.free.nrw.commons.data.Category.Table.TABLE_NAME;
|
||||
import static fr.free.nrw.commons.data.CategoryDao.Table.ALL_FIELDS;
|
||||
import static fr.free.nrw.commons.data.CategoryDao.Table.COLUMN_ID;
|
||||
import static fr.free.nrw.commons.data.CategoryDao.Table.TABLE_NAME;
|
||||
|
||||
public class CategoryContentProvider extends ContentProvider {
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,28 @@
|
|||
package fr.free.nrw.commons.data;
|
||||
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.net.Uri;
|
||||
import android.os.RemoteException;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
|
||||
import fr.free.nrw.commons.category.CategoryContentProvider;
|
||||
|
||||
/**
|
||||
* Represents a category
|
||||
*/
|
||||
public class Category {
|
||||
private Uri contentUri;
|
||||
|
||||
private String name;
|
||||
private Date lastUsed;
|
||||
private int timesUsed;
|
||||
|
||||
// Getters/setters
|
||||
public Category() {
|
||||
}
|
||||
|
||||
public Category(Uri contentUri, String name, Date lastUsed, int timesUsed) {
|
||||
this.contentUri = contentUri;
|
||||
this.name = name;
|
||||
this.lastUsed = lastUsed;
|
||||
this.timesUsed = timesUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets name
|
||||
*
|
||||
|
|
@ -48,21 +46,11 @@ public class Category {
|
|||
*
|
||||
* @return Last used date
|
||||
*/
|
||||
private Date getLastUsed() {
|
||||
public Date getLastUsed() {
|
||||
// warning: Date objects are mutable.
|
||||
return (Date)lastUsed.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies last used date
|
||||
*
|
||||
* @param lastUsed Category date
|
||||
*/
|
||||
public void setLastUsed(Date lastUsed) {
|
||||
// warning: Date objects are mutable.
|
||||
this.lastUsed = (Date)lastUsed.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates new last used date
|
||||
*/
|
||||
|
|
@ -75,19 +63,10 @@ public class Category {
|
|||
*
|
||||
* @return no. of times used
|
||||
*/
|
||||
private int getTimesUsed() {
|
||||
public int getTimesUsed() {
|
||||
return timesUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modifies no. of times used
|
||||
*
|
||||
* @param timesUsed Category used times
|
||||
*/
|
||||
public void setTimesUsed(int timesUsed) {
|
||||
this.timesUsed = timesUsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increments timesUsed by 1 and sets last used date as now.
|
||||
*/
|
||||
|
|
@ -96,181 +75,22 @@ public class Category {
|
|||
touch();
|
||||
}
|
||||
|
||||
//region Database/content-provider stuff
|
||||
|
||||
/**
|
||||
* Persist category.
|
||||
* @param client ContentProviderClient to handle DB connection
|
||||
*/
|
||||
public void save(ContentProviderClient client) {
|
||||
try {
|
||||
if (contentUri == null) {
|
||||
contentUri = client.insert(CategoryContentProvider.BASE_URI, this.toContentValues());
|
||||
} else {
|
||||
client.update(contentUri, toContentValues(), null, null);
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets content values
|
||||
* Gets the content URI for this category
|
||||
*
|
||||
* @return Content values
|
||||
* @return content URI
|
||||
*/
|
||||
private ContentValues toContentValues() {
|
||||
ContentValues cv = new ContentValues();
|
||||
cv.put(Table.COLUMN_NAME, getName());
|
||||
cv.put(Table.COLUMN_LAST_USED, getLastUsed().getTime());
|
||||
cv.put(Table.COLUMN_TIMES_USED, getTimesUsed());
|
||||
return cv;
|
||||
public Uri getContentUri() {
|
||||
return contentUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets category from cursor
|
||||
* @param cursor Category cursor
|
||||
* @return Category from cursor
|
||||
* Modifies the content URI - marking this category as already saved in the database
|
||||
*
|
||||
* @param contentUri the content URI
|
||||
*/
|
||||
private static Category fromCursor(Cursor cursor) {
|
||||
// Hardcoding column positions!
|
||||
Category c = new Category();
|
||||
c.contentUri = CategoryContentProvider.uriForId(cursor.getInt(0));
|
||||
c.name = cursor.getString(1);
|
||||
c.lastUsed = new Date(cursor.getLong(2));
|
||||
c.timesUsed = cursor.getInt(3);
|
||||
return c;
|
||||
public void setContentUri(Uri contentUri) {
|
||||
this.contentUri = contentUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find persisted category in database, based on its name.
|
||||
* @param client ContentProviderClient to handle DB connection
|
||||
* @param name Category's name
|
||||
* @return category from database, or null if not found
|
||||
*/
|
||||
public static @Nullable Category find(ContentProviderClient client, String name) {
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = client.query(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
Category.Table.ALL_FIELDS,
|
||||
Category.Table.COLUMN_NAME + "=?",
|
||||
new String[]{name},
|
||||
null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return Category.fromCursor(cursor);
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
// This feels lazy, but to hell with checked exceptions. :)
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve recently-used categories, ordered by descending date.
|
||||
* @return a list containing recent categories
|
||||
*/
|
||||
public static @NonNull ArrayList<String> recentCategories(ContentProviderClient client, int limit) {
|
||||
ArrayList<String> items = new ArrayList<>();
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = client.query(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
Category.Table.ALL_FIELDS,
|
||||
null,
|
||||
new String[]{},
|
||||
Category.Table.COLUMN_LAST_USED + " DESC");
|
||||
// fixme add a limit on the original query instead of falling out of the loop?
|
||||
while (cursor != null && cursor.moveToNext()
|
||||
&& cursor.getPosition() < limit) {
|
||||
Category cat = Category.fromCursor(cursor);
|
||||
items.add(cat.getName());
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
public static class Table {
|
||||
public static final String TABLE_NAME = "categories";
|
||||
|
||||
public static final String COLUMN_ID = "_id";
|
||||
public static final String COLUMN_NAME = "name";
|
||||
public static final String COLUMN_LAST_USED = "last_used";
|
||||
public static final String COLUMN_TIMES_USED = "times_used";
|
||||
|
||||
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
|
||||
public static final String[] ALL_FIELDS = {
|
||||
COLUMN_ID,
|
||||
COLUMN_NAME,
|
||||
COLUMN_LAST_USED,
|
||||
COLUMN_TIMES_USED
|
||||
};
|
||||
|
||||
private static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
|
||||
+ COLUMN_ID + " INTEGER PRIMARY KEY,"
|
||||
+ COLUMN_NAME + " STRING,"
|
||||
+ COLUMN_LAST_USED + " INTEGER,"
|
||||
+ COLUMN_TIMES_USED + " INTEGER"
|
||||
+ ");";
|
||||
|
||||
/**
|
||||
* Creates new table with provided SQLite database
|
||||
*
|
||||
* @param db Category database
|
||||
*/
|
||||
public static void onCreate(SQLiteDatabase db) {
|
||||
db.execSQL(CREATE_TABLE_STATEMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes existing table
|
||||
* @param db Category database
|
||||
*/
|
||||
public static void onDelete(SQLiteDatabase db) {
|
||||
db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME);
|
||||
onCreate(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates given database
|
||||
* @param db Category database
|
||||
* @param from Exiting category id
|
||||
* @param to New category id
|
||||
*/
|
||||
public static void onUpdate(SQLiteDatabase db, int from, int to) {
|
||||
if (from == to) {
|
||||
return;
|
||||
}
|
||||
if (from < 4) {
|
||||
// doesn't exist yet
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
if (from == 4) {
|
||||
// table added in version 5
|
||||
onCreate(db);
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
if (from == 5) {
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
174
app/src/main/java/fr/free/nrw/commons/data/CategoryDao.java
Normal file
174
app/src/main/java/fr/free/nrw/commons/data/CategoryDao.java
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package fr.free.nrw.commons.data;
|
||||
|
||||
import android.content.ContentProviderClient;
|
||||
import android.content.ContentValues;
|
||||
import android.database.Cursor;
|
||||
import android.database.sqlite.SQLiteDatabase;
|
||||
import android.os.RemoteException;
|
||||
import android.support.annotation.NonNull;
|
||||
import android.support.annotation.Nullable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import fr.free.nrw.commons.category.CategoryContentProvider;
|
||||
|
||||
public class CategoryDao {
|
||||
|
||||
private final ContentProviderClient client;
|
||||
|
||||
public CategoryDao(ContentProviderClient client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public void save(Category category) {
|
||||
try {
|
||||
if (category.getContentUri() == null) {
|
||||
category.setContentUri(client.insert(CategoryContentProvider.BASE_URI, toContentValues(category)));
|
||||
} else {
|
||||
client.update(category.getContentUri(), toContentValues(category), null, null);
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find persisted category in database, based on its name.
|
||||
*
|
||||
* @param name Category's name
|
||||
* @return category from database, or null if not found
|
||||
*/
|
||||
public @Nullable
|
||||
Category find(String name) {
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = client.query(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
Table.COLUMN_NAME + "=?",
|
||||
new String[]{name},
|
||||
null);
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
return fromCursor(cursor);
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
// This feels lazy, but to hell with checked exceptions. :)
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve recently-used categories, ordered by descending date.
|
||||
*
|
||||
* @return a list containing recent categories
|
||||
*/
|
||||
public @NonNull
|
||||
List<String> recentCategories(int limit) {
|
||||
List<String> items = new ArrayList<>();
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = client.query(
|
||||
CategoryContentProvider.BASE_URI,
|
||||
Table.ALL_FIELDS,
|
||||
null,
|
||||
new String[]{},
|
||||
Table.COLUMN_LAST_USED + " DESC");
|
||||
// fixme add a limit on the original query instead of falling out of the loop?
|
||||
while (cursor != null && cursor.moveToNext()
|
||||
&& cursor.getPosition() < limit) {
|
||||
items.add(fromCursor(cursor).getName());
|
||||
}
|
||||
} catch (RemoteException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
Category fromCursor(Cursor cursor) {
|
||||
// Hardcoding column positions!
|
||||
return new Category(
|
||||
CategoryContentProvider.uriForId(cursor.getInt(0)),
|
||||
cursor.getString(1),
|
||||
new Date(cursor.getLong(2)),
|
||||
cursor.getInt(3)
|
||||
);
|
||||
}
|
||||
|
||||
private ContentValues toContentValues(Category category) {
|
||||
ContentValues cv = new ContentValues();
|
||||
cv.put(CategoryDao.Table.COLUMN_NAME, category.getName());
|
||||
cv.put(CategoryDao.Table.COLUMN_LAST_USED, category.getLastUsed().getTime());
|
||||
cv.put(CategoryDao.Table.COLUMN_TIMES_USED, category.getTimesUsed());
|
||||
return cv;
|
||||
}
|
||||
|
||||
public static class Table {
|
||||
public static final String TABLE_NAME = "categories";
|
||||
|
||||
public static final String COLUMN_ID = "_id";
|
||||
static final String COLUMN_NAME = "name";
|
||||
static final String COLUMN_LAST_USED = "last_used";
|
||||
static final String COLUMN_TIMES_USED = "times_used";
|
||||
|
||||
// NOTE! KEEP IN SAME ORDER AS THEY ARE DEFINED UP THERE. HELPS HARD CODE COLUMN INDICES.
|
||||
public static final String[] ALL_FIELDS = {
|
||||
COLUMN_ID,
|
||||
COLUMN_NAME,
|
||||
COLUMN_LAST_USED,
|
||||
COLUMN_TIMES_USED
|
||||
};
|
||||
|
||||
static final String DROP_TABLE_STATEMENT = "DROP TABLE IF EXISTS " + TABLE_NAME;
|
||||
|
||||
static final String CREATE_TABLE_STATEMENT = "CREATE TABLE " + TABLE_NAME + " ("
|
||||
+ COLUMN_ID + " INTEGER PRIMARY KEY,"
|
||||
+ COLUMN_NAME + " STRING,"
|
||||
+ COLUMN_LAST_USED + " INTEGER,"
|
||||
+ COLUMN_TIMES_USED + " INTEGER"
|
||||
+ ");";
|
||||
|
||||
public static void onCreate(SQLiteDatabase db) {
|
||||
db.execSQL(CREATE_TABLE_STATEMENT);
|
||||
}
|
||||
|
||||
public static void onDelete(SQLiteDatabase db) {
|
||||
db.execSQL(DROP_TABLE_STATEMENT);
|
||||
onCreate(db);
|
||||
}
|
||||
|
||||
static void onUpdate(SQLiteDatabase db, int from, int to) {
|
||||
if (from == to) {
|
||||
return;
|
||||
}
|
||||
if (from < 4) {
|
||||
// doesn't exist yet
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
if (from == 4) {
|
||||
// table added in version 5
|
||||
onCreate(db);
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
if (from == 5) {
|
||||
from++;
|
||||
onUpdate(db, from, to);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -23,13 +23,13 @@ public class DBOpenHelper extends SQLiteOpenHelper {
|
|||
public void onCreate(SQLiteDatabase sqLiteDatabase) {
|
||||
ContributionDao.Table.onCreate(sqLiteDatabase);
|
||||
ModifierSequence.Table.onCreate(sqLiteDatabase);
|
||||
Category.Table.onCreate(sqLiteDatabase);
|
||||
CategoryDao.Table.onCreate(sqLiteDatabase);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int from, int to) {
|
||||
ContributionDao.Table.onUpdate(sqLiteDatabase, from, to);
|
||||
ModifierSequence.Table.onUpdate(sqLiteDatabase, from, to);
|
||||
Category.Table.onUpdate(sqLiteDatabase, from, to);
|
||||
CategoryDao.Table.onUpdate(sqLiteDatabase, from, to);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue