Add a tags field to the Android and desktop uploaders
Each photo card gains a free-text tags input, sent as a raw comma-separated `tags` form field on upload. The server normalizes it (parseTags), so the clients stay dumb — no tag parsing on-device. - Android: PickedItem.tags + setTags; threaded through UploadQueue → UploadWorker → UploadClient's multipart. - Desktop: a tags QLineEdit per PhotoItemWidget; UploadClient.upload sends it.
This commit is contained in:
1 parent
a42abfab48
commit
faa1e01c59
11 files changed
+43
-3
No files matched your search
@@ -81,6 +81,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
onCaptionChange = viewModel::setCaption,
|
onCaptionChange = viewModel::setCaption,
|
||||||
|
onTagsChange = viewModel::setTags,
|
||||||
onDateChange = viewModel::setDate,
|
onDateChange = viewModel::setDate,
|
||||||
onRemove = viewModel::removeItem,
|
onRemove = viewModel::removeItem,
|
||||||
onSubmit = viewModel::submit,
|
onSubmit = viewModel::submit,
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class UploadClient(
|
|||||||
fileName: String,
|
fileName: String,
|
||||||
caption: String,
|
caption: String,
|
||||||
date: String,
|
date: String,
|
||||||
|
tags: String,
|
||||||
): UploadOutcome {
|
): UploadOutcome {
|
||||||
val mediaType = if (fileName.endsWith(".png", ignoreCase = true)) {
|
val mediaType = if (fileName.endsWith(".png", ignoreCase = true)) {
|
||||||
"image/png".toMediaType()
|
"image/png".toMediaType()
|
||||||
@@ -72,6 +73,7 @@ class UploadClient(
|
|||||||
.addFormDataPart("image", fileName, file.asRequestBody(mediaType))
|
.addFormDataPart("image", fileName, file.asRequestBody(mediaType))
|
||||||
.addFormDataPart("caption", caption)
|
.addFormDataPart("caption", caption)
|
||||||
.addFormDataPart("date", date)
|
.addFormDataPart("date", date)
|
||||||
|
.addFormDataPart("tags", tags)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ fun PostScreen(
|
|||||||
state: PostUiState,
|
state: PostUiState,
|
||||||
onPickImages: () -> Unit,
|
onPickImages: () -> Unit,
|
||||||
onCaptionChange: (id: String, value: String) -> Unit,
|
onCaptionChange: (id: String, value: String) -> Unit,
|
||||||
|
onTagsChange: (id: String, value: String) -> Unit,
|
||||||
onDateChange: (id: String, value: LocalDate) -> Unit,
|
onDateChange: (id: String, value: LocalDate) -> Unit,
|
||||||
onRemove: (id: String) -> Unit,
|
onRemove: (id: String) -> Unit,
|
||||||
onSubmit: () -> Unit,
|
onSubmit: () -> Unit,
|
||||||
@@ -107,6 +108,7 @@ fun PostScreen(
|
|||||||
ItemCard(
|
ItemCard(
|
||||||
item = item,
|
item = item,
|
||||||
onCaptionChange = { onCaptionChange(item.id, it) },
|
onCaptionChange = { onCaptionChange(item.id, it) },
|
||||||
|
onTagsChange = { onTagsChange(item.id, it) },
|
||||||
onEditDate = { dateEditFor = item },
|
onEditDate = { dateEditFor = item },
|
||||||
onRemove = { onRemove(item.id) },
|
onRemove = { onRemove(item.id) },
|
||||||
onOpenUrl = onOpenUrl,
|
onOpenUrl = onOpenUrl,
|
||||||
@@ -185,6 +187,7 @@ fun PostScreen(
|
|||||||
private fun ItemCard(
|
private fun ItemCard(
|
||||||
item: PickedItem,
|
item: PickedItem,
|
||||||
onCaptionChange: (String) -> Unit,
|
onCaptionChange: (String) -> Unit,
|
||||||
|
onTagsChange: (String) -> Unit,
|
||||||
onEditDate: () -> Unit,
|
onEditDate: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
onOpenUrl: (String) -> Unit,
|
onOpenUrl: (String) -> Unit,
|
||||||
@@ -220,6 +223,16 @@ private fun ItemCard(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
|
OutlinedTextField(
|
||||||
|
value = item.tags,
|
||||||
|
onValueChange = onTagsChange,
|
||||||
|
label = { Text("Tags") },
|
||||||
|
placeholder = { Text("comma-separated") },
|
||||||
|
singleLine = true,
|
||||||
|
enabled = item.status !is ItemStatus.Uploading,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onEditDate,
|
onClick = onEditDate,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ data class PickedItem(
|
|||||||
val source: Uri,
|
val source: Uri,
|
||||||
val preview: Bitmap? = null,
|
val preview: Bitmap? = null,
|
||||||
val caption: String = "",
|
val caption: String = "",
|
||||||
|
val tags: String = "", // raw comma-separated; the server normalizes
|
||||||
val date: LocalDate = LocalDate.now(),
|
val date: LocalDate = LocalDate.now(),
|
||||||
val status: ItemStatus = ItemStatus.Pending,
|
val status: ItemStatus = ItemStatus.Pending,
|
||||||
)
|
)
|
||||||
@@ -114,6 +115,10 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
updateItem(id) { it.copy(caption = value) }
|
updateItem(id) { it.copy(caption = value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setTags(id: String, value: String) {
|
||||||
|
updateItem(id) { it.copy(tags = value) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setDate(id: String, value: LocalDate) {
|
fun setDate(id: String, value: LocalDate) {
|
||||||
updateItem(id) { it.copy(date = value) }
|
updateItem(id) { it.copy(date = value) }
|
||||||
}
|
}
|
||||||
@@ -159,6 +164,7 @@ class UploadViewModel(app: Application) : AndroidViewModel(app) {
|
|||||||
uri = Uri.fromFile(staged),
|
uri = Uri.fromFile(staged),
|
||||||
caption = current.caption,
|
caption = current.caption,
|
||||||
date = current.date,
|
date = current.date,
|
||||||
|
tags = current.tags,
|
||||||
uniqueSuffix = item.id.take(6),
|
uniqueSuffix = item.id.take(6),
|
||||||
)
|
)
|
||||||
observe(item.id, workId)
|
observe(item.id, workId)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ object UploadQueue {
|
|||||||
uri: Uri,
|
uri: Uri,
|
||||||
caption: String,
|
caption: String,
|
||||||
date: LocalDate,
|
date: LocalDate,
|
||||||
|
tags: String,
|
||||||
uniqueSuffix: String,
|
uniqueSuffix: String,
|
||||||
): UUID {
|
): UUID {
|
||||||
val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix
|
val slug = LocalDateTime.now().format(SLUG_FORMAT) + "-" + uniqueSuffix
|
||||||
@@ -45,6 +46,7 @@ object UploadQueue {
|
|||||||
.putString(UploadWorker.KEY_SLUG, slug)
|
.putString(UploadWorker.KEY_SLUG, slug)
|
||||||
.putString(UploadWorker.KEY_CAPTION, caption)
|
.putString(UploadWorker.KEY_CAPTION, caption)
|
||||||
.putString(UploadWorker.KEY_DATE, date.format(ISO_DATE))
|
.putString(UploadWorker.KEY_DATE, date.format(ISO_DATE))
|
||||||
|
.putString(UploadWorker.KEY_TAGS, tags)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
)
|
||||||
.setConstraints(
|
.setConstraints(
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class UploadWorker(
|
|||||||
val slug = inputData.getString(KEY_SLUG) ?: return@withContext Result.failure()
|
val slug = inputData.getString(KEY_SLUG) ?: return@withContext Result.failure()
|
||||||
val caption = inputData.getString(KEY_CAPTION).orEmpty()
|
val caption = inputData.getString(KEY_CAPTION).orEmpty()
|
||||||
val date = inputData.getString(KEY_DATE).orEmpty()
|
val date = inputData.getString(KEY_DATE).orEmpty()
|
||||||
|
val tags = inputData.getString(KEY_TAGS).orEmpty()
|
||||||
|
|
||||||
val settings = SettingsStore(applicationContext)
|
val settings = SettingsStore(applicationContext)
|
||||||
if (!settings.isConfigured) {
|
if (!settings.isConfigured) {
|
||||||
@@ -66,6 +67,7 @@ class UploadWorker(
|
|||||||
fileName = prepared.file.name,
|
fileName = prepared.file.name,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
date = date,
|
date = date,
|
||||||
|
tags = tags,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keep the staged and prepared copies only while a retry might still
|
// Keep the staged and prepared copies only while a retry might still
|
||||||
@@ -147,6 +149,7 @@ class UploadWorker(
|
|||||||
const val KEY_SLUG = "slug"
|
const val KEY_SLUG = "slug"
|
||||||
const val KEY_CAPTION = "caption"
|
const val KEY_CAPTION = "caption"
|
||||||
const val KEY_DATE = "date"
|
const val KEY_DATE = "date"
|
||||||
|
const val KEY_TAGS = "tags"
|
||||||
|
|
||||||
const val KEY_RESULT_SLUG = "result_slug"
|
const val KEY_RESULT_SLUG = "result_slug"
|
||||||
const val KEY_RESULT_URL = "result_url"
|
const val KEY_RESULT_URL = "result_url"
|
||||||
|
|||||||
@@ -313,7 +313,7 @@ void MainWindow::startItem(PhotoItemWidget *item)
|
|||||||
refreshPosting();
|
refreshPosting();
|
||||||
});
|
});
|
||||||
client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(),
|
client->upload(prepared.filePath, QFileInfo(prepared.filePath).fileName(),
|
||||||
caption, item->date());
|
caption, item->date(), item->tags());
|
||||||
});
|
});
|
||||||
watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() {
|
watcher->setFuture(QtConcurrent::run([source, cacheDir, slug]() {
|
||||||
return ImagePrep::prepare(source, cacheDir, slug);
|
return ImagePrep::prepare(source, cacheDir, slug);
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ PhotoItemWidget::PhotoItemWidget(QString sourcePath, QWidget *parent)
|
|||||||
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
|
m_date->setDisplayFormat(QStringLiteral("yyyy-MM-dd"));
|
||||||
fields->addWidget(m_date);
|
fields->addWidget(m_date);
|
||||||
|
|
||||||
|
m_tags = new QLineEdit;
|
||||||
|
m_tags->setPlaceholderText(QStringLiteral("tags (comma-separated)"));
|
||||||
|
fields->addWidget(m_tags);
|
||||||
|
|
||||||
m_statusLabel = new QLabel;
|
m_statusLabel = new QLabel;
|
||||||
m_statusLabel->setOpenExternalLinks(true);
|
m_statusLabel->setOpenExternalLinks(true);
|
||||||
m_statusLabel->setWordWrap(true);
|
m_statusLabel->setWordWrap(true);
|
||||||
@@ -70,6 +74,11 @@ QString PhotoItemWidget::date() const
|
|||||||
return m_date->date().toString(QStringLiteral("yyyy-MM-dd"));
|
return m_date->date().toString(QStringLiteral("yyyy-MM-dd"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QString PhotoItemWidget::tags() const
|
||||||
|
{
|
||||||
|
return m_tags->text();
|
||||||
|
}
|
||||||
|
|
||||||
void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
|
void PhotoItemWidget::setStatus(Status status, const QString &message, const QString &url)
|
||||||
{
|
{
|
||||||
m_status = status;
|
m_status = status;
|
||||||
@@ -79,6 +88,7 @@ void PhotoItemWidget::setStatus(Status status, const QString &message, const QSt
|
|||||||
const bool editable = status == Status::Pending || status == Status::Error;
|
const bool editable = status == Status::Pending || status == Status::Error;
|
||||||
m_caption->setEnabled(editable);
|
m_caption->setEnabled(editable);
|
||||||
m_date->setEnabled(editable);
|
m_date->setEnabled(editable);
|
||||||
|
m_tags->setEnabled(editable);
|
||||||
m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading);
|
m_remove->setEnabled(status != Status::Preparing && status != Status::Uploading);
|
||||||
|
|
||||||
switch (status) {
|
switch (status) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public:
|
|||||||
QString sourcePath() const { return m_sourcePath; }
|
QString sourcePath() const { return m_sourcePath; }
|
||||||
QString caption() const;
|
QString caption() const;
|
||||||
QString date() const; // yyyy-MM-dd
|
QString date() const; // yyyy-MM-dd
|
||||||
|
QString tags() const; // raw comma-separated; the server normalizes
|
||||||
Status status() const { return m_status; }
|
Status status() const { return m_status; }
|
||||||
|
|
||||||
void setStatus(Status status, const QString &message = {}, const QString &url = {});
|
void setStatus(Status status, const QString &message = {}, const QString &url = {});
|
||||||
@@ -35,6 +36,7 @@ private:
|
|||||||
QLabel *m_thumb = nullptr;
|
QLabel *m_thumb = nullptr;
|
||||||
QLineEdit *m_caption = nullptr;
|
QLineEdit *m_caption = nullptr;
|
||||||
QDateEdit *m_date = nullptr;
|
QDateEdit *m_date = nullptr;
|
||||||
|
QLineEdit *m_tags = nullptr;
|
||||||
QLabel *m_statusLabel = nullptr;
|
QLabel *m_statusLabel = nullptr;
|
||||||
QPushButton *m_remove = nullptr;
|
QPushButton *m_remove = nullptr;
|
||||||
};
|
};
|
||||||
@@ -26,7 +26,7 @@ QByteArray UploadClient::authHeader() const
|
|||||||
}
|
}
|
||||||
|
|
||||||
void UploadClient::upload(const QString &filePath, const QString &fileName,
|
void UploadClient::upload(const QString &filePath, const QString &fileName,
|
||||||
const QString &caption, const QString &date)
|
const QString &caption, const QString &date, const QString &tags)
|
||||||
{
|
{
|
||||||
auto *file = new QFile(filePath);
|
auto *file = new QFile(filePath);
|
||||||
if (!file->open(QIODevice::ReadOnly)) {
|
if (!file->open(QIODevice::ReadOnly)) {
|
||||||
@@ -57,6 +57,7 @@ void UploadClient::upload(const QString &filePath, const QString &fileName,
|
|||||||
};
|
};
|
||||||
multiPart->append(textPart(QStringLiteral("caption"), caption));
|
multiPart->append(textPart(QStringLiteral("caption"), caption));
|
||||||
multiPart->append(textPart(QStringLiteral("date"), date));
|
multiPart->append(textPart(QStringLiteral("date"), date));
|
||||||
|
multiPart->append(textPart(QStringLiteral("tags"), tags));
|
||||||
|
|
||||||
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
|
QNetworkRequest request(QUrl(m_base + QStringLiteral("/upload")));
|
||||||
request.setRawHeader("Authorization", authHeader());
|
request.setRawHeader("Authorization", authHeader());
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public:
|
|||||||
// The filename matters: server.go derives the slug from it, so re-sending
|
// The filename matters: server.go derives the slug from it, so re-sending
|
||||||
// the same name overwrites the same post instead of duplicating it.
|
// the same name overwrites the same post instead of duplicating it.
|
||||||
void upload(const QString &filePath, const QString &fileName,
|
void upload(const QString &filePath, const QString &fileName,
|
||||||
const QString &caption, const QString &date);
|
const QString &caption, const QString &date, const QString &tags);
|
||||||
|
|
||||||
// Credential check via GET /upload (same Basic-auth gate the POST uses).
|
// Credential check via GET /upload (same Basic-auth gate the POST uses).
|
||||||
// /health is not usable: nginx only proxies location /upload.
|
// /health is not usable: nginx only proxies location /upload.
|
||||||
|
|||||||
Reference in new issue
Block a user