fix: use client-side FileReader for CSV upload
Multipart form uploads fail because Indiekit has no multipart parsing middleware. Instead, read the CSV file client-side with FileReader and submit the text content as a hidden form field. Shows file name and line count after selection for user confidence.
This commit is contained in:
@@ -55,10 +55,13 @@ export function migratePostController(mountPath, pluginOptions) {
|
|||||||
const importFollowing = request.body.importTypes?.includes("following");
|
const importFollowing = request.body.importTypes?.includes("following");
|
||||||
const importFollowers = request.body.importTypes?.includes("followers");
|
const importFollowers = request.body.importTypes?.includes("followers");
|
||||||
|
|
||||||
// Read uploaded file — express-fileupload or raw body
|
// Read file content (submitted as text via client-side FileReader)
|
||||||
const fileContent = extractFileContent(request);
|
const fileContent = request.body.csvContent?.trim();
|
||||||
if (!fileContent) {
|
if (!fileContent) {
|
||||||
result = { type: "error", text: "No file uploaded" };
|
result = {
|
||||||
|
type: "error",
|
||||||
|
text: response.locals.__("activitypub.migrate.errorNoFile"),
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
let followingResult = { imported: 0, failed: 0 };
|
let followingResult = { imported: 0, failed: 0 };
|
||||||
let followersResult = { imported: 0, failed: 0 };
|
let followersResult = { imported: 0, failed: 0 };
|
||||||
@@ -104,20 +107,3 @@ export function migratePostController(mountPath, pluginOptions) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract file content from the request.
|
|
||||||
* Supports express-fileupload (request.files) and raw text body.
|
|
||||||
*/
|
|
||||||
function extractFileContent(request) {
|
|
||||||
// express-fileupload attaches to request.files
|
|
||||||
if (request.files?.csvFile) {
|
|
||||||
return request.files.csvFile.data.toString("utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: file content submitted as text in a textarea
|
|
||||||
if (request.body.csvContent) {
|
|
||||||
return request.body.csvContent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"importFollowersHint": "Your current followers — they will be recorded as pending until they re-follow you after the Move in step 3",
|
"importFollowersHint": "Your current followers — they will be recorded as pending until they re-follow you after the Move in step 3",
|
||||||
"step3Title": "Step 3 — Move your account",
|
"step3Title": "Step 3 — Move your account",
|
||||||
"step3Desc": "Once you have saved your alias and imported your data, go to your Mastodon instance → Preferences → Account → <strong>Move to a different account</strong>. Enter your new fediverse handle and confirm. Mastodon will notify all your followers, and those whose servers support it will automatically re-follow you here. This step is irreversible — your old account will become a redirect.",
|
"step3Desc": "Once you have saved your alias and imported your data, go to your Mastodon instance → Preferences → Account → <strong>Move to a different account</strong>. Enter your new fediverse handle and confirm. Mastodon will notify all your followers, and those whose servers support it will automatically re-follow you here. This step is irreversible — your old account will become a redirect.",
|
||||||
|
"errorNoFile": "Please select a CSV file before importing.",
|
||||||
"success": "Imported %d following, %d followers (%d failed).",
|
"success": "Imported %d following, %d followers (%d failed).",
|
||||||
"aliasSuccess": "Alias saved — your actor document now includes this account as alsoKnownAs."
|
"aliasSuccess": "Alias saved — your actor document now includes this account as alsoKnownAs."
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||||
"version": "0.1.6",
|
"version": "0.1.7",
|
||||||
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"indiekit",
|
"indiekit",
|
||||||
|
|||||||
@@ -4,10 +4,8 @@
|
|||||||
{% from "input/macro.njk" import input with context %}
|
{% from "input/macro.njk" import input with context %}
|
||||||
{% from "button/macro.njk" import button with context %}
|
{% from "button/macro.njk" import button with context %}
|
||||||
{% from "checkboxes/macro.njk" import checkboxes with context %}
|
{% from "checkboxes/macro.njk" import checkboxes with context %}
|
||||||
{% from "file-input/macro.njk" import fileInput with context %}
|
|
||||||
{% from "notification-banner/macro.njk" import notificationBanner with context %}
|
{% from "notification-banner/macro.njk" import notificationBanner with context %}
|
||||||
{% from "prose/macro.njk" import prose with context %}
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
{% from "badge/macro.njk" import badge with context %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
{{ heading({ text: title, level: 1, parent: { text: __("activitypub.title"), href: mountPath } }) }}
|
||||||
@@ -49,8 +47,9 @@
|
|||||||
{{ heading({ text: __("activitypub.migrate.step2Title"), level: 2 }) }}
|
{{ heading({ text: __("activitypub.migrate.step2Title"), level: 2 }) }}
|
||||||
{{ prose({ text: __("activitypub.migrate.step2Desc") }) }}
|
{{ prose({ text: __("activitypub.migrate.step2Desc") }) }}
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data" novalidate>
|
<form method="post" novalidate x-data="csvImport()">
|
||||||
<input type="hidden" name="action" value="import">
|
<input type="hidden" name="action" value="import">
|
||||||
|
<input type="hidden" name="csvContent" :value="csvContent">
|
||||||
|
|
||||||
{{ checkboxes({
|
{{ checkboxes({
|
||||||
name: "importTypes",
|
name: "importTypes",
|
||||||
@@ -72,12 +71,20 @@
|
|||||||
values: ["following"]
|
values: ["following"]
|
||||||
}) }}
|
}) }}
|
||||||
|
|
||||||
{{ fileInput({
|
<div class="field">
|
||||||
name: "csvFile",
|
<label class="label" for="csvFile">{{ __("activitypub.migrate.fileLabel") }}</label>
|
||||||
label: __("activitypub.migrate.fileLabel"),
|
<p class="hint">{{ __("activitypub.migrate.fileHint") }}</p>
|
||||||
hint: __("activitypub.migrate.fileHint"),
|
<input class="input" type="file" id="csvFile" accept=".csv,.txt"
|
||||||
accept: ".csv,.txt"
|
@change="readFile($event)">
|
||||||
}) }}
|
<template x-if="fileName">
|
||||||
|
<p class="hint" style="margin-top: 0.5em">
|
||||||
|
<strong x-text="fileName"></strong> — <span x-text="lineCount + ' lines'"></span>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template x-if="fileError">
|
||||||
|
<p class="hint" style="margin-top: 0.5em; color: var(--color-error, #d4351c)" x-text="fileError"></p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
{{ button({ text: __("activitypub.migrate.importButton") }) }}
|
{{ button({ text: __("activitypub.migrate.importButton") }) }}
|
||||||
</form>
|
</form>
|
||||||
@@ -87,4 +94,44 @@
|
|||||||
{# Step 3 — Instructions #}
|
{# Step 3 — Instructions #}
|
||||||
{{ heading({ text: __("activitypub.migrate.step3Title"), level: 2 }) }}
|
{{ heading({ text: __("activitypub.migrate.step3Title"), level: 2 }) }}
|
||||||
{{ prose({ text: __("activitypub.migrate.step3Desc") }) }}
|
{{ prose({ text: __("activitypub.migrate.step3Desc") }) }}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function csvImport() {
|
||||||
|
return {
|
||||||
|
csvContent: '',
|
||||||
|
fileName: '',
|
||||||
|
lineCount: 0,
|
||||||
|
fileError: '',
|
||||||
|
|
||||||
|
readFile(event) {
|
||||||
|
var self = this;
|
||||||
|
self.csvContent = '';
|
||||||
|
self.fileName = '';
|
||||||
|
self.lineCount = 0;
|
||||||
|
self.fileError = '';
|
||||||
|
|
||||||
|
var file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
self.fileError = 'File too large (max 5 MB)';
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
var text = e.target.result;
|
||||||
|
self.csvContent = text;
|
||||||
|
self.fileName = file.name;
|
||||||
|
self.lineCount = text.split('\n').filter(function(l) { return l.trim(); }).length;
|
||||||
|
};
|
||||||
|
reader.onerror = function() {
|
||||||
|
self.fileError = 'Could not read file';
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user