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:
110
web/src/lib/stores/trail_merge_api.ts
Normal file
110
web/src/lib/stores/trail_merge_api.ts
Normal 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();
|
||||
}
|
||||
26
web/src/lib/stores/trail_merge_i18n.ts
Normal file
26
web/src/lib/stores/trail_merge_i18n.ts
Normal 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;
|
||||
}
|
||||
81
web/src/lib/stores/trail_merge_store.svelte.ts
Normal file
81
web/src/lib/stores/trail_merge_store.svelte.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user