importer feature

This commit is contained in:
2026-05-10 21:23:56 +02:00
parent a47e3ae4e6
commit f2f1317660
77 changed files with 4884 additions and 1810 deletions

View File

@@ -1,6 +1,8 @@
use application::ports::{
ActivityFeedPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
LoginPageData, NewReviewPageData, ProfilePageData, RegisterPageData, UsersPageData,
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
ImportRowStatus, ImportUploadPageData, LoginPageData, NewReviewPageData, ProfilePageData,
RegisterPageData, UsersPageData,
};
use askama::Template;
use chrono::Datelike;
@@ -290,6 +292,34 @@ fn bar_height_px(avg_rating: f64) -> i64 {
(avg_rating / 5.0 * 60.0) as i64
}
#[derive(Template)]
#[template(path = "import_upload.html")]
struct ImportUploadTemplate<'a> {
ctx: &'a HtmlPageContext,
profiles: &'a [ImportProfileView],
error: Option<&'a str>,
}
#[derive(Template)]
#[template(path = "import_mapping.html")]
struct ImportMappingTemplate<'a> {
ctx: &'a HtmlPageContext,
session_id: &'a str,
columns: &'a [String],
sample_rows: &'a [Vec<String>],
domain_fields: &'a [(&'static str, &'static str)],
error: Option<&'a str>,
}
#[derive(Template)]
#[template(path = "import_preview.html")]
struct ImportPreviewTemplate<'a> {
ctx: &'a HtmlPageContext,
session_id: &'a str,
columns: &'a [String],
rows: &'a [ImportPreviewRow],
}
pub struct AskamaHtmlRenderer;
impl AskamaHtmlRenderer {
@@ -557,4 +587,38 @@ impl HtmlRenderer for AskamaHtmlRenderer {
.render()
.map_err(|e| e.to_string())
}
fn render_import_upload_page(&self, data: ImportUploadPageData) -> Result<String, String> {
ImportUploadTemplate {
ctx: &data.ctx,
profiles: &data.profiles,
error: data.error.as_deref(),
}
.render()
.map_err(|e| e.to_string())
}
fn render_import_mapping_page(&self, data: ImportMappingPageData) -> Result<String, String> {
ImportMappingTemplate {
ctx: &data.ctx,
session_id: &data.session_id,
columns: &data.columns,
sample_rows: &data.sample_rows,
domain_fields: &data.domain_fields,
error: data.error.as_deref(),
}
.render()
.map_err(|e| e.to_string())
}
fn render_import_preview_page(&self, data: ImportPreviewPageData) -> Result<String, String> {
ImportPreviewTemplate {
ctx: &data.ctx,
session_id: &data.session_id,
columns: &data.columns,
rows: &data.rows,
}
.render()
.map_err(|e| e.to_string())
}
}

View File

@@ -33,6 +33,7 @@
{% if let Some(uid) = ctx.user_id %}
<a href="/users/{{ uid }}">Profile</a>
<a href="/reviews/new">Add Review</a>
<a href="/import">Import</a>
<a href="/logout">Logout</a>
{% else %}
<a href="/login">Login</a>

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<h1>Map Columns</h1>
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
<p>Showing up to 5 sample rows. Map each column to a diary field.</p>
<form method="POST" action="/import/{{ session_id }}/mapping">
<table>
<thead>
<tr>
{% for col in columns %}<th>{{ col }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in sample_rows %}
<tr>{% for cell in row %}<td>{{ cell }}</td>{% endfor %}</tr>
{% endfor %}
</tbody>
</table>
{% for col in columns %}
<fieldset>
<legend>{{ col }}</legend>
<label>Maps to
<select name="mapping_{{ loop.index0 }}_field">
<option value="">— skip —</option>
{% for (val, label) in domain_fields %}
<option value="{{ val }}">{{ label }}</option>
{% endfor %}
</select>
</label>
<label>Rating scale
<select name="mapping_{{ loop.index0 }}_scale">
<option value="1.0">Same (05)</option>
<option value="0.5">10-point (/2)</option>
<option value="0.05">Percentage (/20)</option>
</select>
</label>
<label>Date format
<input type="text" name="mapping_{{ loop.index0 }}_datefmt" placeholder="%Y-%m-%d">
</label>
<input type="hidden" name="mapping_{{ loop.index0 }}_col" value="{{ col }}">
</fieldset>
{% endfor %}
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Preview Import</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block content %}
<h1>Preview Import</h1>
<form method="POST" action="/import/{{ session_id }}/confirm">
<table>
<thead>
<tr>
<th>Include?</th>
{% for col in columns %}<th>{{ col }}</th>{% endfor %}
<th>Status</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>
{% match row.status %}
{% when ImportRowStatus::Invalid with (_e) %}
<input type="checkbox" disabled>
{% when _ %}
<input type="checkbox" name="confirmed" value="{{ row.index }}" checked>
{% endmatch %}
</td>
{% for cell in row.cells %}<td>{{ cell }}</td>{% endfor %}
<td>
{% match row.status %}
{% when ImportRowStatus::Valid %}&#10003;
{% when ImportRowStatus::Duplicate %}&#9888; duplicate
{% when ImportRowStatus::Invalid with (e) %}&#10007; {{ e }}
{% endmatch %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<label>Save this mapping as a profile?
<input type="text" name="profile_name" placeholder="e.g. Letterboxd">
</label>
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Import Selected</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block content %}
<h1>Import Reviews</h1>
{% if let Some(err) = error %}
<p class="error">{{ err }}</p>
{% endif %}
{% if !profiles.is_empty() %}
<section>
<h2>Saved Profiles</h2>
<ul>
{% for p in profiles %}
<li>
{{ p.name }}
<form method="POST" action="/import/profiles/{{ p.id }}/delete" style="display:inline">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Delete</button>
</form>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
<h2>Upload File</h2>
<form method="POST" action="/import/upload" enctype="multipart/form-data">
<label>
File (CSV, TSV, JSON, XLSX)<br>
<input type="file" name="file" accept=".csv,.tsv,.json,.xlsx" required>
</label>
<label>
Format<br>
<select name="format">
<option value="csv">CSV / TSV</option>
<option value="json">JSON</option>
<option value="xlsx">XLSX</option>
</select>
</label>
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<button type="submit">Upload</button>
</form>
{% endblock %}