merge trails function added (#627)

* merge trails function added

* fix conflict resolve issues

* avoid incorrect authorship when merging trail comments

* require edit access for all selected trails before merge

* fix likes checkbox binding in trail merge modal

* improve error handling

* require editable target and deletable sources for merge

* avoid duplicate trail fetch during merge

* prevent duplicate likes when merging trails

* check photo download responses during trail merge

* add merge completion toast notifications

* cleanup

* small fixes

* move merge code to backend, option to merge single trail selection with similar one, automatic merge for integrations add as option, maintenance page to find and merge similar trails added

* fix build error

* fix

* docu, beautifying, refactoring

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>
This commit is contained in:
slothful-vassal
2026-05-09 13:09:26 +02:00
committed by GitHub
parent d2268429c7
commit b5739cb72e
43 changed files with 5232 additions and 216 deletions

View File

@@ -0,0 +1,110 @@
import type { MergeSettings } from "$lib/components/trail/trail_merge_modal.svelte";
import { APIError } from "$lib/util/api_util";
export interface TrailMergeSuggestCandidate {
trailId: string;
score: number;
reason: string;
warnings: string[];
selectable: boolean;
}
export interface TrailMergeSuggestResponse {
targetTrailId: string;
reason: string;
warnings: string[];
candidates: TrailMergeSuggestCandidate[];
}
export interface TrailMergeSuggestGroup {
groupId: string;
trailIds: string[];
targetTrailId: string;
reason: string;
score: number;
indirect: boolean;
}
export interface TrailMergeSuggestGroupsResponse {
groups: TrailMergeSuggestGroup[];
}
export async function trail_merge_suggest_manual(trailIds: string[]) {
const response = await fetch("/api/v1/trail-merge/suggest", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
mode: "manual-selection",
trailIds,
}),
});
if (!response.ok) {
const error = await response.json();
throw new APIError(response.status, error.message, error.detail);
}
return await response.json() as TrailMergeSuggestResponse;
}
export async function trail_merge_suggest_auto(sourceTrailId: string) {
const response = await fetch("/api/v1/trail-merge/suggest", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
mode: "auto-discovery",
sourceTrailId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new APIError(response.status, error.message, error.detail);
}
return await response.json() as TrailMergeSuggestResponse;
}
export async function trail_merge_suggest_groups() {
const response = await fetch("/api/v1/trail-merge/suggest", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
mode: "maintenance-groups",
}),
});
if (!response.ok) {
const error = await response.json();
throw new APIError(response.status, error.message, error.detail);
}
return await response.json() as TrailMergeSuggestGroupsResponse;
}
export async function trail_merge(sourceTrailId: string, targetTrailId: string, settings: MergeSettings) {
const response = await fetch("/api/v1/trail-merge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
sourceTrailId,
targetTrailId,
settings,
}),
});
if (!response.ok) {
const error = await response.json();
throw new APIError(response.status, error.message, error.detail);
}
return await response.json();
}

View File

@@ -0,0 +1,26 @@
import { get } from "svelte/store";
import { _ } from "svelte-i18n";
const mergeErrorCodes = new Set([
"trail_merge_auth_required",
"trail_merge_actor_not_found",
"trail_merge_invalid_request",
"trail_merge_unknown_suggest_mode",
"trail_merge_missing_actor",
"trail_merge_missing_trail_id",
"trail_merge_same_source_target",
"trail_merge_requires_multiple_trails",
"trail_merge_missing_source_trail_id",
"trail_merge_source_actor_mismatch",
"trail_merge_source_not_found",
"trail_merge_target_not_found",
"trail_merge_not_allowed",
]);
export function translateTrailMergeError(message: string): string {
if (mergeErrorCodes.has(message)) {
return get(_)(message);
}
return message;
}

View File

@@ -0,0 +1,81 @@
import type { MergeSettings } from "$lib/components/trail/trail_merge_modal.svelte";
import type { Trail } from "$lib/models/trail";
import { APIError } from "$lib/util/api_util";
import { get } from "svelte/store";
import { _ } from "svelte-i18n";
import { translateTrailMergeError } from "./trail_merge_i18n";
export type Merge = {
trailTarget: Trail,
trailSource: Trail;
status: "enqueued" | "merging" | "cancelled" | "success" | "error";
error?: string;
progress: number;
settings: MergeSettings;
function: (t: Trail, t2: Trail, settings: MergeSettings, onProgress?: (p: number) => void) => Promise<unknown>
};
class MergeStore {
enqueuedMerges: Merge[] = $state([]);
completedMerges: Merge[] = $state([]);
merging: boolean = $state(false);
}
export const mergeStore = new MergeStore();
function getMergeErrorMessage(error: unknown): string {
if (error instanceof APIError) {
return translateTrailMergeError(error.message);
}
if (error instanceof Error) {
return translateTrailMergeError(error.message);
}
if (typeof error === "string") {
return translateTrailMergeError(error);
}
return get(_)("trail-merge-unknown-error");
}
export async function processMergeQueue(batchSize: number = 3) {
if (mergeStore.merging) {
return;
}
mergeStore.merging = true;
const completedBeforeRun = mergeStore.completedMerges.length;
try {
while (mergeStore.enqueuedMerges.length > 0) {
const batch = mergeStore.enqueuedMerges.slice(0, batchSize);
const mergePromises: Promise<unknown>[] = [];
for (const b of batch) {
b.status = "merging";
mergePromises.push(
b.function(b.trailTarget, b.trailSource, b.settings, (p: number) => {
b.progress = p
})
);
}
const results = await Promise.all(
mergePromises.map((p) => p.catch((e) => e)),
);
results.forEach((r, i) => {
const u = batch[i];
if (r instanceof Error || typeof r === "string") {
u.status = "error";
u.error = getMergeErrorMessage(r);
} else {
u.status = "success";
u.error = undefined;
}
mergeStore.completedMerges.push(u);
});
mergeStore.enqueuedMerges.splice(0, batchSize)
}
} finally {
mergeStore.merging = false;
}
void completedBeforeRun;
}