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:
@@ -3,6 +3,7 @@
|
||||
text: string;
|
||||
value: any;
|
||||
icon?: string;
|
||||
separator?: boolean;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -130,16 +131,22 @@
|
||||
bind:this={dropdownElement}
|
||||
>
|
||||
{#each items as item}
|
||||
<li
|
||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
role="presentation"
|
||||
onclick={(e) => handleItemClick(e, item)}
|
||||
>
|
||||
{#if item.icon}
|
||||
<i class="fa fa-{item.icon} mr-3"></i>
|
||||
{/if}
|
||||
<span class="whitespace-nowrap">{item.text}</span>
|
||||
</li>
|
||||
{#if item.separator}
|
||||
<li class="px-3 py-2" role="separator" aria-hidden="true">
|
||||
<div class="border-t border-input-border"></div>
|
||||
</li>
|
||||
{:else}
|
||||
<li
|
||||
class="menu-item flex items-center px-4 py-3 cursor-pointer hover:bg-menu-item-background-hover focus:bg-menu-item-background-focus transition-colors"
|
||||
role="presentation"
|
||||
onclick={(e) => handleItemClick(e, item as { text: string; value: any })}
|
||||
>
|
||||
{#if item.icon}
|
||||
<i class="fa fa-{item.icon} mr-3"></i>
|
||||
{/if}
|
||||
<span class="whitespace-nowrap">{item.text}</span>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import TextField from "$lib/components/base/text_field.svelte";
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
@@ -33,6 +34,9 @@
|
||||
planned: integration?.hammerhead?.planned ?? true,
|
||||
active: integration?.hammerhead?.active ?? false,
|
||||
after: integration?.hammerhead?.after,
|
||||
merge: integration?.hammerhead?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -104,6 +108,8 @@
|
||||
><i class="fa fa-close"></i></button
|
||||
>
|
||||
</div>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import Toggle from "$lib/components/base/toggle.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
|
||||
interface Props {
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
let { prefix }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="pt-4 border-t border-input-border space-y-2">
|
||||
<Toggle
|
||||
name={`${prefix}.enabled`}
|
||||
label={$_("integration-auto-merge-label")}
|
||||
/>
|
||||
<p class="text-xs text-gray-500 max-w-lg">
|
||||
{$_("integration-auto-merge-hint")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Select, {
|
||||
type SelectItem,
|
||||
} from "$lib/components/base/select.svelte";
|
||||
@@ -43,6 +44,9 @@
|
||||
planned: integration?.komoot?.planned ?? true,
|
||||
active: integration?.komoot?.active ?? false,
|
||||
privacy: integration?.komoot?.privacy ?? "original",
|
||||
merge: integration?.komoot?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -106,6 +110,8 @@
|
||||
{$_("integration-privacy-hint-user")}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import Datepicker from "$lib/components/base/datepicker.svelte";
|
||||
import IntegrationMergeSettings from "$lib/components/settings/integrations/integration_merge_settings.svelte";
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import type { SelectItem } from "$lib/components/base/select.svelte";
|
||||
import Select from "$lib/components/base/select.svelte";
|
||||
@@ -43,7 +44,10 @@
|
||||
activities: integration?.strava?.activities ?? true,
|
||||
active: integration?.strava?.active ?? false,
|
||||
after: integration?.strava?.after,
|
||||
privacy: integration?.komoot?.privacy ?? "original",
|
||||
privacy: integration?.strava?.privacy ?? "original",
|
||||
merge: integration?.strava?.merge ?? {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -130,6 +134,8 @@
|
||||
>
|
||||
{$_("strava-integration-after-date-hint")}
|
||||
</p>
|
||||
|
||||
<IntegrationMergeSettings prefix="merge" />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
lists_remove_trail,
|
||||
} from "$lib/stores/list_store";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
import { trails_delete, trails_update } from "$lib/stores/trail_store";
|
||||
import {
|
||||
trails_delete,
|
||||
trails_show,
|
||||
trails_update,
|
||||
} from "$lib/stores/trail_store";
|
||||
import { currentUser } from "$lib/stores/user_store";
|
||||
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
|
||||
import { getFileURL, saveAs } from "$lib/util/file_util";
|
||||
@@ -30,6 +34,21 @@
|
||||
import TrailExportModal from "./trail_export_modal.svelte";
|
||||
import TrailSendModal from "./trail_send_modal.svelte";
|
||||
import TrailShareModal from "./trail_share_modal.svelte";
|
||||
import {
|
||||
mergeStore,
|
||||
processMergeQueue,
|
||||
type Merge,
|
||||
} from "$lib/stores/trail_merge_store.svelte";
|
||||
import TrailMergeModal from "./trail_merge_modal.svelte";
|
||||
import type { MergeSelection, MergeSettings } from "./trail_merge_modal.svelte";
|
||||
import MergeDialog from "$lib/components/trail/trail_merge_dialog.svelte";
|
||||
import { trail_merge } from "$lib/stores/trail_merge_api";
|
||||
|
||||
export interface MergeResult {
|
||||
targetTrail: Trail;
|
||||
deletedTrailIds: string[];
|
||||
successfulMergeCount: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
trails?: Set<Trail> | undefined;
|
||||
@@ -38,14 +57,16 @@
|
||||
onDelete?: () => void;
|
||||
onShare?: () => void;
|
||||
onUpdate?: () => void;
|
||||
onMerge?: (result: MergeResult) => void;
|
||||
}
|
||||
|
||||
let { trails, mode, toggle, onDelete, onShare, onUpdate }: Props = $props();
|
||||
let { trails, mode, toggle, onDelete, onShare, onUpdate, onMerge }: Props = $props();
|
||||
|
||||
let confirmModal: ConfirmModal;
|
||||
let listSelectModal: ListSearchModal;
|
||||
let trailExportModal: TrailExportModal;
|
||||
let trailShareModal: TrailShareModal;
|
||||
let trailMergeModal: TrailMergeModal;
|
||||
let trailSendModal: TrailSendModal;
|
||||
|
||||
const hammerheadIntegration = $derived(
|
||||
@@ -99,18 +120,75 @@
|
||||
|
||||
let loading: boolean = $state(false);
|
||||
|
||||
function canEditTrail(candidate: Trail | undefined): boolean {
|
||||
if (!candidate || !$currentUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
candidate.expand?.author?.id === $currentUser.actor ||
|
||||
Boolean(
|
||||
candidate.expand?.trail_share_via_trail?.some(
|
||||
(s) =>
|
||||
s.permission == "edit" &&
|
||||
s.actor == $currentUser.actor,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getMergeTarget(): Trail | undefined {
|
||||
if (!trails || trails.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const candidate of trails) {
|
||||
if (
|
||||
candidate.expand?.summit_logs_via_trail &&
|
||||
candidate.expand.summit_logs_via_trail.length > 0
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return [...trails][0];
|
||||
}
|
||||
|
||||
function allowEdit(): boolean {
|
||||
return (
|
||||
hasTrail() &&
|
||||
!isMultiselectMode() &&
|
||||
Boolean($currentUser) &&
|
||||
(trail()!.expand?.author?.id === $currentUser?.actor ||
|
||||
trail()!.expand?.trail_share_via_trail?.some(
|
||||
(s) => s.permission == "edit",
|
||||
))!
|
||||
canEditTrail(trail())
|
||||
);
|
||||
}
|
||||
|
||||
function allowMerge(): boolean {
|
||||
if (!hasTrail() || !isMultiselectMode() || !Boolean($currentUser)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetTrail = getMergeTarget();
|
||||
if (!canEditTrail(targetTrail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const cTrail of trails ?? []) {
|
||||
if (cTrail.id === targetTrail?.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!allowDeleteTrail(cTrail)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function allowFindSimilarTrails(): boolean {
|
||||
return hasTrail() && !isMultiselectMode() && Boolean($currentUser) && hasGpx() && isFromCurrentUser(trail());
|
||||
}
|
||||
|
||||
function majorityOfSelectedTrailsArePublic(): boolean {
|
||||
if (trails === undefined || trails.size === 0) return false;
|
||||
|
||||
@@ -119,13 +197,7 @@
|
||||
let publicCount = 0;
|
||||
|
||||
for (const cTrail of trails) {
|
||||
if (cTrail.expand?.author === undefined) return false;
|
||||
if (
|
||||
cTrail.expand!.author!.id !== $currentUser?.actor &&
|
||||
!cTrail.expand?.trail_share_via_trail?.some(
|
||||
(s) => s.permission == "edit",
|
||||
)
|
||||
) {
|
||||
if (!canEditTrail(cTrail)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -152,13 +224,7 @@
|
||||
}
|
||||
|
||||
for (const cTrail of trails) {
|
||||
if (cTrail.expand?.author === undefined) return false;
|
||||
if (
|
||||
cTrail.expand!.author!.id !== $currentUser?.actor &&
|
||||
!cTrail.expand?.trail_share_via_trail?.some(
|
||||
(s) => s.permission == "edit",
|
||||
)
|
||||
) {
|
||||
if (!canEditTrail(cTrail)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -167,8 +233,70 @@
|
||||
}
|
||||
|
||||
function dropdownItems(): DropdownItem[] {
|
||||
const separator = (value: string): DropdownItem => ({
|
||||
text: "",
|
||||
value,
|
||||
separator: true,
|
||||
});
|
||||
const allowListManagement = isFromCurrentUser();
|
||||
const allowShareSingleTrail = !isMultiselectMode() && isFromCurrentUser();
|
||||
const allowSingleOutput = canExport() || (!isMultiselectMode() && hammerheadIntegration && canExport());
|
||||
|
||||
if (isMultiselectMode()) {
|
||||
return [
|
||||
...(allowMerge()
|
||||
? [{ text: $_("link"), value: "merge", icon: "link" }]
|
||||
: []),
|
||||
...(allowMerge() && (canExport() || allowListManagement || allowPublish() || allowDelete())
|
||||
? [separator("sep-multi-actions")]
|
||||
: []),
|
||||
...(canExport()
|
||||
? [
|
||||
{
|
||||
text: $_("export"),
|
||||
value: "download",
|
||||
icon: "download",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!allowListManagement
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: $_("add-to-list"),
|
||||
value: "list",
|
||||
icon: "bookmark",
|
||||
},
|
||||
]),
|
||||
...(((canExport() || allowListManagement) && (allowPublish() || allowDelete()))
|
||||
? [separator("sep-multi-visibility")]
|
||||
: []),
|
||||
...(allowPublish()
|
||||
? [
|
||||
{
|
||||
text: `${majorityOfSelectedTrailsArePublic() ? $_("set-private") : $_("set-public")}`,
|
||||
value: "publish",
|
||||
icon: majorityOfSelectedTrailsArePublic()
|
||||
? "lock"
|
||||
: "globe",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(allowDelete()
|
||||
? [
|
||||
separator("sep-multi-danger"),
|
||||
{
|
||||
text: $_("delete"),
|
||||
value: "delete",
|
||||
icon: "trash",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...(!isMultiselectMode()
|
||||
...(hasTrail()
|
||||
? [
|
||||
mode == "overview" || mode == "multi-select"
|
||||
? {
|
||||
@@ -181,10 +309,6 @@
|
||||
value: "show",
|
||||
icon: "table-columns",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isMultiselectMode()
|
||||
? [
|
||||
{
|
||||
text: $_("directions"),
|
||||
value: "direction",
|
||||
@@ -192,6 +316,69 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((hasTrail() && (allowEdit() || allowFindSimilarTrails() || allowCopy()))
|
||||
? [separator("sep-single-edit")]
|
||||
: []),
|
||||
...(allowEdit()
|
||||
? [
|
||||
{
|
||||
text: $_("edit"),
|
||||
value: "edit",
|
||||
icon: "pen",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(allowFindSimilarTrails()
|
||||
? [{
|
||||
text: $_("find-similar-trails"),
|
||||
value: "find-similar-trails",
|
||||
icon: "link",
|
||||
}]
|
||||
: []),
|
||||
...(allowCopy()
|
||||
? [
|
||||
{
|
||||
text: $_("duplicate"),
|
||||
value: "copy",
|
||||
icon: "copy",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((allowListManagement || allowShareSingleTrail || allowPublish())
|
||||
? [separator("sep-single-organize")]
|
||||
: []),
|
||||
...(!allowListManagement
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: $_("add-to-list"),
|
||||
value: "list",
|
||||
icon: "bookmark",
|
||||
},
|
||||
]),
|
||||
...(!allowShareSingleTrail
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: $_("share"),
|
||||
value: "share",
|
||||
icon: "share",
|
||||
},
|
||||
]),
|
||||
...(allowPublish()
|
||||
? [
|
||||
{
|
||||
text: `${majorityOfSelectedTrailsArePublic() ? $_("set-private") : $_("set-public")}`,
|
||||
value: "publish",
|
||||
icon: majorityOfSelectedTrailsArePublic()
|
||||
? "lock"
|
||||
: "globe",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...((allowSingleOutput || allowDelete())
|
||||
? [separator("sep-single-output")]
|
||||
: []),
|
||||
...(canExport()
|
||||
? [
|
||||
{
|
||||
@@ -201,6 +388,15 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isMultiselectMode() && hammerheadIntegration && canExport()
|
||||
? [
|
||||
{
|
||||
text: $_("send-to"),
|
||||
value: "send-to",
|
||||
icon: "upload",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isMultiselectMode()
|
||||
? [
|
||||
{
|
||||
@@ -210,55 +406,9 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isFromCurrentUser()
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: $_("add-to-list"),
|
||||
value: "list",
|
||||
icon: "bookmark",
|
||||
},
|
||||
]),
|
||||
...(isMultiselectMode() || !isFromCurrentUser()
|
||||
? []
|
||||
: [
|
||||
{
|
||||
text: $_("share"),
|
||||
value: "share",
|
||||
icon: "share",
|
||||
},
|
||||
]),
|
||||
...(allowCopy()
|
||||
? [
|
||||
{
|
||||
text: $_("duplicate"),
|
||||
value: "copy",
|
||||
icon: "copy",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(allowPublish()
|
||||
? [
|
||||
{
|
||||
text: `${majorityOfSelectedTrailsArePublic() ? $_("set-private") : $_("set-public")}`,
|
||||
value: "publish",
|
||||
icon: majorityOfSelectedTrailsArePublic()
|
||||
? "lock"
|
||||
: "globe",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(allowEdit()
|
||||
? [
|
||||
{
|
||||
text: $_("edit"),
|
||||
value: "edit",
|
||||
icon: "pen",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(allowDelete()
|
||||
? [
|
||||
separator("sep-single-danger"),
|
||||
{
|
||||
text: $_("delete"),
|
||||
value: "delete",
|
||||
@@ -266,15 +416,6 @@
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isMultiselectMode() && hammerheadIntegration && canExport()
|
||||
? [
|
||||
{
|
||||
text: $_("send-to"),
|
||||
value: "send-to",
|
||||
icon: "upload",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -399,11 +540,69 @@
|
||||
updateTrailsVisibility();
|
||||
} else if (ddVal == "delete") {
|
||||
confirmModal.openModal();
|
||||
} else if (item.value == "merge") {
|
||||
await trailMergeModal.openModal(Array.from(trails ?? []));
|
||||
} else if (item.value == "find-similar-trails") {
|
||||
if (trail()) {
|
||||
await trailMergeModal.openSimilarTrailsModal(trail()!);
|
||||
}
|
||||
} else if (item.value == "send-to") {
|
||||
trailSendModal.openModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function mergeTrails(settings: MergeSettings, selection: MergeSelection) {
|
||||
let trailTarget = selection.targetTrail;
|
||||
if (!trailTarget.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trailTarget.expand) {
|
||||
trailTarget = await trails_show(trailTarget.id);
|
||||
}
|
||||
|
||||
for (const t of selection.sourceTrails) {
|
||||
if (t.id === trailTarget.id) continue;
|
||||
|
||||
const u: Merge = {
|
||||
trailTarget: trailTarget,
|
||||
trailSource: t,
|
||||
progress: 0,
|
||||
status: "enqueued",
|
||||
settings: settings,
|
||||
function: trails_merge_backend
|
||||
};
|
||||
mergeStore.enqueuedMerges.push(u);
|
||||
}
|
||||
|
||||
const completedBeforeRun = mergeStore.completedMerges.length;
|
||||
await processMergeQueue();
|
||||
const completedThisRun = mergeStore.completedMerges.slice(completedBeforeRun);
|
||||
const successfulMerges = completedThisRun.filter(
|
||||
(merge) => merge.status === "success",
|
||||
);
|
||||
const successfulThisRun = successfulMerges.length;
|
||||
|
||||
if (settings.delete && successfulThisRun > 0) {
|
||||
onMerge?.({
|
||||
targetTrail: trailTarget,
|
||||
deletedTrailIds: successfulMerges
|
||||
.map((merge) => merge.trailSource.id)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
successfulMergeCount: successfulThisRun,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function trails_merge_backend(trailTarget: Trail, trailSource: Trail, settings: MergeSettings, onProgress?: (progress: number) => void) {
|
||||
if (!trailTarget.id || !trailSource.id) {
|
||||
throw new Error($_("error-merging-trail"));
|
||||
}
|
||||
|
||||
onProgress?.(0.2);
|
||||
await trail_merge(trailSource.id, trailTarget.id, settings);
|
||||
onProgress?.(1);
|
||||
}
|
||||
async function uploadToHammerhead() {
|
||||
if (!hammerheadIntegration || !hasTrail()) {
|
||||
console.error("No Hammerhead integration found.");
|
||||
@@ -725,6 +924,12 @@
|
||||
onsave={handleShareUpdate}
|
||||
bind:this={trailShareModal}
|
||||
></TrailShareModal>
|
||||
<TrailMergeModal
|
||||
bind:this={trailMergeModal}
|
||||
onmerge={(settings, selection) => mergeTrails(settings, selection)}
|
||||
></TrailMergeModal>
|
||||
|
||||
<MergeDialog/>
|
||||
<TrailSendModal
|
||||
bind:this={trailSendModal}
|
||||
onsend={async (settings) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { page } from "$app/state";
|
||||
import Tabs from "$lib/components/base/tabs.svelte";
|
||||
import TrailDropdown from "$lib/components/trail/trail_dropdown.svelte";
|
||||
import TrailDropdown, { type MergeResult } from "$lib/components/trail/trail_dropdown.svelte";
|
||||
import { Comment } from "$lib/models/comment";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
|
||||
@@ -269,6 +270,21 @@
|
||||
summitLogs.set(newSummitLogList);
|
||||
}
|
||||
|
||||
function handleTrailMerge(result: MergeResult) {
|
||||
if (!trail.id || !result.deletedTrailIds.includes(trail.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetHandle = handleFromRecordWithIRI(result.targetTrail);
|
||||
const targetPath =
|
||||
mode === "map"
|
||||
? `/map/trail/${targetHandle}/${result.targetTrail.id}`
|
||||
: `/trail/view/${targetHandle}/${result.targetTrail.id}`;
|
||||
|
||||
const search = page.url.searchParams.toString();
|
||||
goto(search ? `${targetPath}?${search}` : targetPath);
|
||||
}
|
||||
|
||||
async function markTrailAsCompleted() {
|
||||
trail.completed = true;
|
||||
const updatedTrail: Trail = { ...trail };
|
||||
@@ -430,6 +446,7 @@
|
||||
trails={new Set<Trail>([trail])}
|
||||
onDelete={() =>
|
||||
history.length ? history.back() : goto("/trails")}
|
||||
onMerge={handleTrailMerge}
|
||||
{mode}
|
||||
></TrailDropdown>
|
||||
</div>
|
||||
|
||||
@@ -298,6 +298,15 @@
|
||||
else hoveredTrail = undefined;
|
||||
}
|
||||
|
||||
async function handleTrailsMergeDone(resetSelection: boolean = false) {
|
||||
if (resetSelection) {
|
||||
selection?.clear();
|
||||
hoveredTrail = undefined;
|
||||
}
|
||||
await tick();
|
||||
onupdate?.(filter, selection);
|
||||
}
|
||||
|
||||
async function handleTrailsEditDone(resetSelection: boolean = false) {
|
||||
if (resetSelection) {
|
||||
selection = new Set<Trail>();
|
||||
@@ -346,6 +355,7 @@
|
||||
mode={"multi-select"}
|
||||
onDelete={() => handleTrailsEditDone(true)}
|
||||
onShare={() => handleTrailsEditDone(false)}
|
||||
onMerge={() => handleTrailsMergeDone(true)}
|
||||
onUpdate={() => handleTrailsEditDone(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
177
web/src/lib/components/trail/trail_merge_dialog.svelte
Normal file
177
web/src/lib/components/trail/trail_merge_dialog.svelte
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
processMergeQueue,
|
||||
mergeStore,
|
||||
type Merge,
|
||||
} from "$lib/stores/trail_merge_store.svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let minimized: boolean = $state(false);
|
||||
|
||||
let visibleMerges = $derived(
|
||||
mergeStore.enqueuedMerges
|
||||
.concat(mergeStore.completedMerges)
|
||||
.sort((a, b) =>
|
||||
(a.trailSource.name || a.trailSource.id || "").localeCompare(
|
||||
b.trailSource.name || b.trailSource.id || "",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
let remaining = $derived(mergeStore.enqueuedMerges.length);
|
||||
|
||||
let successfulMerges = $derived(
|
||||
mergeStore.completedMerges.reduce(
|
||||
(sum, u) => (sum += u.status == "success" ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
let errorMerges = $derived(
|
||||
mergeStore.completedMerges.reduce(
|
||||
(sum, u) => (sum += u.status == "error" ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
function dismissMerge(u: Merge) {
|
||||
const index = mergeStore.completedMerges.indexOf(u);
|
||||
mergeStore.completedMerges.splice(index, 1);
|
||||
}
|
||||
|
||||
function dismissAllCompleted() {
|
||||
mergeStore.completedMerges = [];
|
||||
}
|
||||
|
||||
function cancelMerge(u: Merge) {
|
||||
const index = mergeStore.enqueuedMerges.indexOf(u);
|
||||
u.status = "cancelled";
|
||||
mergeStore.enqueuedMerges.splice(index, 1);
|
||||
mergeStore.completedMerges.push(u);
|
||||
}
|
||||
|
||||
function reMerge(u: Merge) {
|
||||
const index = mergeStore.completedMerges.indexOf(u);
|
||||
u.status = "enqueued";
|
||||
u.progress = 0;
|
||||
u.error = undefined;
|
||||
mergeStore.completedMerges.splice(index, 1);
|
||||
mergeStore.enqueuedMerges.push(u);
|
||||
processMergeQueue(undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if visibleMerges.length}
|
||||
<div
|
||||
class="fixed bottom-4 right-4 z-10 p-4 bg-background rounded-xl border border-input-border shadow-xl"
|
||||
class:cursor-pointer={minimized}
|
||||
in:slide
|
||||
out:slide
|
||||
role="presentation"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
minimized = false;
|
||||
}}
|
||||
>
|
||||
<div class="flex gap-x-2 items-start justify-between">
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{$_("trail-merge-summary", {
|
||||
values: {
|
||||
remaining,
|
||||
processed: mergeStore.completedMerges.length,
|
||||
total:
|
||||
mergeStore.enqueuedMerges.length +
|
||||
mergeStore.completedMerges.length,
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
{$_("trail-merge-result-summary", {
|
||||
values: {
|
||||
success: successfulMerges,
|
||||
errors: errorMerges,
|
||||
},
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<button title={$_('clear-all')} aria-label={$_('clear-all')} onclick={dismissAllCompleted}
|
||||
><i class="fa fa-ban"></i></button
|
||||
>
|
||||
<button
|
||||
aria-label={$_("minimize")}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
minimized = true;
|
||||
}}><i class="fa fa-minus"></i></button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-96 max-w-72 mt-4 overflow-y-auto space-y-2"
|
||||
class:hidden={minimized}
|
||||
>
|
||||
{#each visibleMerges as u}
|
||||
<div class="bg-menu-item-background-hover rounded-lg py-2 px-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 shrink-0">
|
||||
{#if u.status === "enqueued" || u.status == "merging"}
|
||||
<div class="spinner spinner-small"></div>
|
||||
{:else}
|
||||
<i
|
||||
class={{
|
||||
fa: true,
|
||||
"fa-circle-exclamation text-red-400":
|
||||
u.status == "error",
|
||||
"fa-circle-check text-emerald-400":
|
||||
u.status == "success",
|
||||
"fa-ban text-gray-500":
|
||||
u.status == "cancelled",
|
||||
}}
|
||||
></i>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs basis-full min-w-0 break-all mr-2">
|
||||
{u.trailSource.name}
|
||||
</p>
|
||||
{#if u.status == "error" || u.status == "cancelled"}
|
||||
<button
|
||||
aria-label={$_("trail-merge-retry")}
|
||||
onclick={() => reMerge(u)}
|
||||
><i class="fa fa-redo text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
{#if u.status == "enqueued"}
|
||||
<button
|
||||
aria-label={$_("trail-merge-cancel")}
|
||||
onclick={() => cancelMerge(u)}
|
||||
><i class="fa fa-stop text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
{#if u.status != "enqueued" && u.status != "merging"}
|
||||
<button
|
||||
aria-label={$_("dismiss")}
|
||||
onclick={() => dismissMerge(u)}
|
||||
><i class="fa fa-close text-sm"></i></button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if u.status == "merging"}
|
||||
<div
|
||||
class="progress-bar my-1 rounded-md"
|
||||
style="height:2px; width:{u.progress * 100}%; background-color:#3549bb;transition: width 0.5s ease-in-out;"
|
||||
></div>
|
||||
{:else if u.error}
|
||||
<p class="text-red-400 text-xs">
|
||||
{u.error}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
514
web/src/lib/components/trail/trail_merge_modal.svelte
Normal file
514
web/src/lib/components/trail/trail_merge_modal.svelte
Normal file
@@ -0,0 +1,514 @@
|
||||
<script lang="ts">
|
||||
import Modal from "$lib/components/base/modal.svelte";
|
||||
import Select, { type SelectItem } from "$lib/components/base/select.svelte";
|
||||
import { trails_show } from "$lib/stores/trail_store";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import {
|
||||
trail_merge_suggest_auto,
|
||||
trail_merge_suggest_manual,
|
||||
type TrailMergeSuggestCandidate,
|
||||
} from "$lib/stores/trail_merge_api";
|
||||
import { translateTrailMergeError } from "$lib/stores/trail_merge_i18n";
|
||||
import { show_toast } from "$lib/stores/toast_store.svelte";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
|
||||
export interface MergeSelection {
|
||||
targetTrail: Trail;
|
||||
sourceTrails: Trail[];
|
||||
}
|
||||
|
||||
export interface OpenMergeModalOptions {
|
||||
preferredTargetTrailId?: string;
|
||||
fixedTargetTrailId?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
onmerge?: (settings: MergeSettings, selection: MergeSelection) => void;
|
||||
}
|
||||
|
||||
let { title = $_("link-as-summit-log"), onmerge: onmerge }: Props = $props();
|
||||
|
||||
const STORAGE_KEY_SETTINGS = "trail_merge_settings";
|
||||
const STORAGE_KEY_REMEMBER = "trail_merge_remember";
|
||||
|
||||
let modal: Modal;
|
||||
let loading = $state(false);
|
||||
let candidateWarnings: Record<string, string[]> = $state({});
|
||||
let candidateReasons: Record<string, string> = $state({});
|
||||
let selectableTargets: Trail[] = $state([]);
|
||||
let targetTrailId = $state("");
|
||||
let modalTitle = $state("");
|
||||
let autoDiscoveryMode = $state(false);
|
||||
let fixedTargetSelection = $state(false);
|
||||
let mergeTrailsSelection: Trail[] = $state([]);
|
||||
let autoDiscoverySourceTrail: Trail | undefined = $state();
|
||||
let autoDiscoveryCandidateTrails: Trail[] = $state([]);
|
||||
|
||||
const defaultSettings: MergeSettings = {
|
||||
summitLog: true,
|
||||
photos: true,
|
||||
comments: true,
|
||||
delete: true,
|
||||
tags: true,
|
||||
likes: true,
|
||||
};
|
||||
|
||||
let rememberSettings = $state(false);
|
||||
|
||||
function applySettings(next: MergeSettings) {
|
||||
settings.summitLog = next.summitLog;
|
||||
settings.photos = next.photos;
|
||||
settings.comments = next.comments;
|
||||
settings.delete = next.delete;
|
||||
settings.tags = next.tags;
|
||||
settings.likes = next.likes;
|
||||
}
|
||||
|
||||
function loadStoredSettings() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
applySettings(defaultSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
rememberSettings = localStorage.getItem(STORAGE_KEY_REMEMBER) === "true";
|
||||
if (!rememberSettings) {
|
||||
applySettings(defaultSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = localStorage.getItem(STORAGE_KEY_SETTINGS);
|
||||
if (!raw) {
|
||||
applySettings(defaultSettings);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<MergeSettings>;
|
||||
applySettings({
|
||||
summitLog: parsed.summitLog ?? defaultSettings.summitLog,
|
||||
photos: parsed.photos ?? defaultSettings.photos,
|
||||
comments: parsed.comments ?? defaultSettings.comments,
|
||||
delete: parsed.delete ?? defaultSettings.delete,
|
||||
tags: parsed.tags ?? defaultSettings.tags,
|
||||
likes: parsed.likes ?? defaultSettings.likes,
|
||||
});
|
||||
} catch {
|
||||
applySettings(defaultSettings);
|
||||
}
|
||||
}
|
||||
|
||||
function persistSettingsPreference() {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_REMEMBER, rememberSettings ? "true" : "false");
|
||||
if (!rememberSettings) {
|
||||
localStorage.removeItem(STORAGE_KEY_SETTINGS);
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY_SETTINGS,
|
||||
JSON.stringify({
|
||||
summitLog: settings.summitLog,
|
||||
photos: settings.photos,
|
||||
comments: settings.comments,
|
||||
delete: settings.delete,
|
||||
tags: settings.tags,
|
||||
likes: settings.likes,
|
||||
} satisfies MergeSettings),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadAutoDiscoveryTargets(
|
||||
sourceTrail: Trail,
|
||||
): Promise<{
|
||||
targetTrailId: string;
|
||||
warnings: Record<string, string[]>;
|
||||
reasons: Record<string, string>;
|
||||
selectableTargets: Trail[];
|
||||
}> {
|
||||
if (!sourceTrail.id) {
|
||||
throw new Error($_("trail_merge_missing_source_trail_id"));
|
||||
}
|
||||
|
||||
const response = await trail_merge_suggest_auto(sourceTrail.id);
|
||||
const selectableCandidates = response.candidates.filter(
|
||||
(candidate) => candidate.selectable,
|
||||
);
|
||||
|
||||
const loadedTargets = await Promise.all(
|
||||
selectableCandidates.map(async (candidate) => {
|
||||
const trail = await trails_show(candidate.trailId);
|
||||
return { candidate, trail };
|
||||
}),
|
||||
);
|
||||
|
||||
const warnings: Record<string, string[]> = {};
|
||||
const reasons: Record<string, string> = {};
|
||||
|
||||
for (const { candidate } of loadedTargets) {
|
||||
warnings[candidate.trailId] = candidate.warnings;
|
||||
reasons[candidate.trailId] = candidate.reason;
|
||||
}
|
||||
|
||||
const targets = loadedTargets.map(({ trail }) => trail);
|
||||
|
||||
return {
|
||||
targetTrailId:
|
||||
targets.find((trail) => trail.id === response.targetTrailId)?.id ??
|
||||
targets[0]?.id ??
|
||||
"",
|
||||
warnings,
|
||||
reasons,
|
||||
selectableTargets: targets,
|
||||
};
|
||||
}
|
||||
|
||||
export async function openModal(trails: Trail[] = [], options: OpenMergeModalOptions = {}) {
|
||||
loading = true;
|
||||
modalTitle = title;
|
||||
autoDiscoveryMode = false;
|
||||
fixedTargetSelection = false;
|
||||
mergeTrailsSelection = trails;
|
||||
autoDiscoverySourceTrail = undefined;
|
||||
autoDiscoveryCandidateTrails = [];
|
||||
candidateWarnings = {};
|
||||
candidateReasons = {};
|
||||
selectableTargets = [];
|
||||
targetTrailId = "";
|
||||
|
||||
loadStoredSettings();
|
||||
modal.openModal();
|
||||
|
||||
if (options.fixedTargetTrailId) {
|
||||
const fixedTargetTrail = trails.find((trail) => trail.id === options.fixedTargetTrailId);
|
||||
if (!fixedTargetTrail?.id) {
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("trail-merge-no-editable-target"),
|
||||
});
|
||||
modal.closeModal();
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
fixedTargetSelection = true;
|
||||
selectableTargets = [fixedTargetTrail];
|
||||
targetTrailId = fixedTargetTrail.id;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await trail_merge_suggest_manual(
|
||||
trails.map((trail) => trail.id!).filter(Boolean),
|
||||
);
|
||||
|
||||
const nextWarnings: Record<string, string[]> = {};
|
||||
const nextReasons: Record<string, string> = {};
|
||||
|
||||
const candidatesById = new Map<string, TrailMergeSuggestCandidate>(
|
||||
response.candidates.map((candidate) => [candidate.trailId, candidate]),
|
||||
);
|
||||
|
||||
const nextSelectableTargets = trails.filter((trail) => {
|
||||
const candidate = candidatesById.get(trail.id!);
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nextWarnings[trail.id!] = candidate.warnings;
|
||||
nextReasons[trail.id!] = candidate.reason;
|
||||
return candidate.selectable;
|
||||
});
|
||||
|
||||
candidateWarnings = nextWarnings;
|
||||
candidateReasons = nextReasons;
|
||||
selectableTargets = nextSelectableTargets;
|
||||
|
||||
if (selectableTargets.length === 0) {
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text: $_("trail-merge-no-editable-target"),
|
||||
});
|
||||
modal.closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
targetTrailId =
|
||||
selectableTargets.find((trail) => trail.id === options.preferredTargetTrailId)?.id
|
||||
?? selectableTargets.find((trail) => trail.id === response.targetTrailId)?.id
|
||||
?? selectableTargets[0]?.id
|
||||
?? "";
|
||||
} catch (error) {
|
||||
console.error("Failed to suggest trail merge target", error);
|
||||
const text =
|
||||
error instanceof APIError
|
||||
? translateTrailMergeError(error.message)
|
||||
: $_("trail-merge-suggest-error");
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text,
|
||||
});
|
||||
modal.closeModal();
|
||||
return;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openSimilarTrailsModal(sourceTrail: Trail) {
|
||||
loading = true;
|
||||
modalTitle = $_("find-similar-trails");
|
||||
autoDiscoveryMode = true;
|
||||
mergeTrailsSelection = [sourceTrail];
|
||||
autoDiscoverySourceTrail = sourceTrail;
|
||||
candidateWarnings = {};
|
||||
candidateReasons = {};
|
||||
selectableTargets = [];
|
||||
autoDiscoveryCandidateTrails = [];
|
||||
targetTrailId = "";
|
||||
|
||||
loadStoredSettings();
|
||||
modal.openModal();
|
||||
|
||||
try {
|
||||
const result = await loadAutoDiscoveryTargets(sourceTrail);
|
||||
candidateWarnings = result.warnings;
|
||||
candidateReasons = result.reasons;
|
||||
autoDiscoveryCandidateTrails = result.selectableTargets;
|
||||
selectableTargets = [sourceTrail, ...result.selectableTargets];
|
||||
candidateWarnings[sourceTrail.id!] = [];
|
||||
candidateReasons[sourceTrail.id!] = "selected_trail";
|
||||
targetTrailId = result.targetTrailId || sourceTrail.id || "";
|
||||
|
||||
if (autoDiscoveryCandidateTrails.length === 0) {
|
||||
show_toast({
|
||||
type: "warning",
|
||||
icon: "triangle-exclamation",
|
||||
text: $_("trail-merge-no-similar-trails"),
|
||||
});
|
||||
modal.closeModal();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to find similar trails", error);
|
||||
const text =
|
||||
error instanceof APIError
|
||||
? translateTrailMergeError(error.message)
|
||||
: $_("trail-merge-suggest-error");
|
||||
show_toast({
|
||||
type: "error",
|
||||
icon: "close",
|
||||
text,
|
||||
});
|
||||
modal.closeModal();
|
||||
return;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface MergeSettings {
|
||||
summitLog: boolean;
|
||||
photos: boolean;
|
||||
comments: boolean;
|
||||
delete: boolean;
|
||||
tags: boolean;
|
||||
likes: boolean;
|
||||
}
|
||||
|
||||
const settings: MergeSettings = $state({
|
||||
...defaultSettings,
|
||||
});
|
||||
|
||||
function mergeTrail() {
|
||||
if (!targetTrailId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetTrail = selectableTargets.find((trail) => trail.id === targetTrailId);
|
||||
if (!targetTrail) {
|
||||
return;
|
||||
}
|
||||
|
||||
let sourceTrails = mergeTrailsSelection.filter((trail) => trail.id !== targetTrail.id);
|
||||
if (autoDiscoveryMode && autoDiscoverySourceTrail) {
|
||||
sourceTrails =
|
||||
targetTrail.id === autoDiscoverySourceTrail.id
|
||||
? autoDiscoveryCandidateTrails.filter((trail) => trail.id !== targetTrail.id)
|
||||
: [autoDiscoverySourceTrail];
|
||||
}
|
||||
|
||||
if (sourceTrails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
persistSettingsPreference();
|
||||
onmerge?.(settings, {
|
||||
targetTrail,
|
||||
sourceTrails,
|
||||
});
|
||||
modal.closeModal();
|
||||
}
|
||||
|
||||
let warnings = $derived(targetTrailId ? (candidateWarnings[targetTrailId] ?? []) : []);
|
||||
let selectedReason = $derived(targetTrailId ? candidateReasons[targetTrailId] : "");
|
||||
let targetTrailItems = $derived.by((): SelectItem[] =>
|
||||
selectableTargets.map((trail) => ({
|
||||
text: trail.name ?? "",
|
||||
value: trail.id ?? "",
|
||||
})),
|
||||
);
|
||||
</script>
|
||||
|
||||
<Modal id="merge-modal" title={modalTitle} size="min-w-md" bind:this={modal}>
|
||||
{#snippet content()}
|
||||
<div>
|
||||
{#if loading}
|
||||
<div class="py-6 flex flex-col items-center justify-center gap-3">
|
||||
<div class="spinner light:spinner-dark"></div>
|
||||
<p class="text-sm text-gray-500">
|
||||
{autoDiscoveryMode
|
||||
? $_("trail-merge-loading-similar-trails")
|
||||
: $_("trail-merge-loading-targets")}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
{#if !fixedTargetSelection}
|
||||
<h4 class="font-semibold mb-2">{$_("trail-merge-target")}</h4>
|
||||
<Select
|
||||
name="trail-merge-target"
|
||||
items={targetTrailItems}
|
||||
bind:value={targetTrailId}
|
||||
disabled={selectableTargets.length <= 1}
|
||||
/>
|
||||
{/if}
|
||||
{#if autoDiscoveryMode}
|
||||
<p class="text-sm text-gray-500 mt-2">
|
||||
{$_("trail-merge-similar-trails-found", {
|
||||
values: { n: autoDiscoveryCandidateTrails.length },
|
||||
})}
|
||||
</p>
|
||||
{/if}
|
||||
{#if selectedReason}
|
||||
<p class="text-sm text-gray-500 mt-2">{$_(`trail-merge-reason-${selectedReason}`)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if warnings.length > 0}
|
||||
<div class="mb-4 rounded-xl border border-yellow-500/40 bg-yellow-500/10 p-3">
|
||||
<h4 class="font-semibold mb-2">{$_("trail-merge-warnings-title")}</h4>
|
||||
<ul class="text-sm space-y-1">
|
||||
{#each warnings as warning}
|
||||
<li>{$_(`trail-merge-warning-${warning}`)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h4 class="font-semibold mb-2">{$_("copy-include-elements")}</h4>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-summit-log-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.summitLog}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-summit-log-checkbox" class="ms-2 text-sm"
|
||||
>{$_("summit-log", { values: { n: 2 } })}</label
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-photos-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.photos}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-photos-checkbox" class="ms-2 text-sm"
|
||||
>{$_("photos")}</label
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-comments-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.comments}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-comments-checkbox" class="ms-2 text-sm"
|
||||
>{$_("comment", { values: { n: 2 } })}</label
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-tags-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.tags}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-tags-checkbox" class="ms-2 text-sm"
|
||||
>{$_("tags")}</label
|
||||
>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-likes-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.likes}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-likes-checkbox" class="ms-2 text-sm"
|
||||
>{$_("likes")}</label
|
||||
>
|
||||
</div>
|
||||
<h4 class="font-semibold mt-4 mb-2">{$_("linked-trails")}</h4>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
id="include-trail-delete-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={settings.delete}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<label for="include-trail-delete-checkbox" class="ms-2 text-sm"
|
||||
>{$_("delete-linked-trails")}</label
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<label for="remember-merge-settings-checkbox" class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
id="remember-merge-settings-checkbox"
|
||||
type="checkbox"
|
||||
bind:checked={rememberSettings}
|
||||
class="w-4 h-4 bg-input-background accent-primary border-input-border focus:ring-input-ring focus:ring-2"
|
||||
/>
|
||||
<span>{$_("trail-merge-remember-settings-short")}</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-4">
|
||||
<button class="btn-secondary" onclick={() => modal.closeModal()}
|
||||
>{$_("cancel")}</button
|
||||
>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
disabled={loading || !targetTrailId}
|
||||
onclick={mergeTrail}
|
||||
name="save">{$_("link")}</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}</Modal
|
||||
>
|
||||
@@ -75,6 +75,7 @@
|
||||
"changelog": "Änderungshistorie",
|
||||
"chinese": "Chinesisch (vereinfacht)",
|
||||
"clear-all": "Alle ausblenden",
|
||||
"dismiss": "Schließen",
|
||||
"climbing": "Klettern",
|
||||
"close": "Schließen",
|
||||
"collapse-trail-list": "",
|
||||
@@ -90,7 +91,11 @@
|
||||
"connect": "Verbinden",
|
||||
"continue": "Fortfahren",
|
||||
"contribute": "Mitwirken",
|
||||
"copy-comments": "Kommentare übernehmen",
|
||||
"copy-include-elements": "Zu übernehmende Elemente",
|
||||
"copy-link": "Link kopieren",
|
||||
"copy-summit-log-photos": "Photos aus Gipfelbuch-Einträgen übernehmen",
|
||||
"copy-summit-logs": "Gipfelbuch-Einträge übernehmen",
|
||||
"create-new-list": "Neue Liste erstellen",
|
||||
"create-waypoint": "Wegpunkt erstellen",
|
||||
"create-waypoint-anyway": "Trotzdem erstellen",
|
||||
@@ -108,6 +113,7 @@
|
||||
"degrees": "Grad",
|
||||
"delete": "Löschen",
|
||||
"delete-account": "Konto löschen",
|
||||
"delete-linked-trails": "Verknüpfte Routen löschen",
|
||||
"delete-list-confirm": "Möchtest Du diese Liste wirklich löschen? Die Routen in der Liste sind danach weiterhin verfügbar.",
|
||||
"delete-summit-log-confirm": "Möchtest du diesen Gipfelbuch-Eintrag wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"delete-trail-confirm": "Möchtest Du diese Route wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
@@ -158,6 +164,7 @@
|
||||
"error-liking-trail": "Error liking trail",
|
||||
"error-logging-in-to-hammerhead": "Fehler bei der Anmeldung bei Hammerhead",
|
||||
"error-logging-in-to-komoot": "Fehler bei der Anmeldung bei komoot",
|
||||
"error-merging-trail": "Fehler beim Verknüpfen der Routen",
|
||||
"error-posting-comment": "Fehler beim Posten des Kommentars",
|
||||
"error-printing-map": "Fehler beim Drucken der Karte",
|
||||
"error-reading-file": "Fehler beim Lesen der Datei",
|
||||
@@ -186,6 +193,7 @@
|
||||
"filter-categories": "Kategorien filtern",
|
||||
"filter-difficulty": "Schwierigkeit filtern",
|
||||
"filter-tags": "Tags filtern",
|
||||
"find-similar-trails": "Mit ähnlicher Route verknüpfen",
|
||||
"finish": "Ziel",
|
||||
"fixed-speed": "Fixe Geschwindigkeit",
|
||||
"focus-map-on": "Karte fokussieren auf",
|
||||
@@ -233,6 +241,8 @@
|
||||
"integration-description-hammerhead": "Synchronisiert Deine Hammerhead-Touren regelmäßig mit wanderer.",
|
||||
"integration-description-komoot": "Synchronisiert Deine komoot-Touren regelmäßig mit wanderer.",
|
||||
"integration-description-strava": "Synchronisiert Deine Strava-Routen und -Aktivitäten regelmäßig mit wanderer.",
|
||||
"integration-auto-merge-label": "Automatisch mergen",
|
||||
"integration-auto-merge-hint": "Nach dem Import wird nur dann automatisch gemergt, wenn ein eindeutiger Treffer gefunden wird. Der importierte Trail wird in diesem Fall nicht separat gespeichert.",
|
||||
"integration-disabled": "Integration deaktiviert",
|
||||
"integration-enabled": "Integration aktiviert",
|
||||
"integration-privacy-hint-original": "",
|
||||
@@ -253,8 +263,12 @@
|
||||
"liked": "Gefällt mir",
|
||||
"likes": "Likes",
|
||||
"limited": "Begrenzt",
|
||||
"link-copied": "Link kopiert!",
|
||||
"link": "Verknüpfen",
|
||||
"link-as-summit-log": "Als Gipfelbuch-Eintrag verknüpfen",
|
||||
"link-copied": "Link kopiert!",
|
||||
"link-trails": "Routen verknüpfen",
|
||||
"linked-lists": "Verknüpfte Listen",
|
||||
"linked-trails": "Verknüpfte Routen",
|
||||
"list": "{n, plural, =1 {Liste} other {Listen}}",
|
||||
"list-not-shared": "Mit niemandem geteilt",
|
||||
"list-public-warning": "Alle Routen in dieser Liste werden veröffentlicht.",
|
||||
@@ -427,6 +441,7 @@
|
||||
"show-in-overview": "In der Übersicht anzeigen",
|
||||
"show-less": "Weniger anzeigen",
|
||||
"show-on-map": "Auf der Karte anzeigen",
|
||||
"similar-trails": "Wiederholte Routen / Duplikate",
|
||||
"shower": "Dusche",
|
||||
"skiing": "Skifahren",
|
||||
"slogan": "Speichere deine Abenteuer!",
|
||||
@@ -443,6 +458,8 @@
|
||||
"subway-stop": "U-Bahn Eingang",
|
||||
"summit": "Gipfel",
|
||||
"summit-book": "Gipfelbuch",
|
||||
"summit-log": "{n, plural, =1 {Gipfelbuch-Eintrag} other {Gipfelbuch-Einträge}}",
|
||||
"summit-log-photos": "Photos aus Gipfelbuch-Einträgen",
|
||||
"table": "Tabelle",
|
||||
"tags": "Tags",
|
||||
"text": "Text",
|
||||
@@ -456,6 +473,70 @@
|
||||
"trail-not-in-list": "Trail gehört zu keiner Liste.",
|
||||
"trail-not-shared": "Mit niemandem geteilt",
|
||||
"trail-saved-successfully": "Route gespeichert",
|
||||
"trail-merge-complete": "{success} Routen-Verknüpfungen abgeschlossen.",
|
||||
"trail-merge-complete-with-errors": "{success} Routen-Verknüpfungen abgeschlossen, {errors} fehlgeschlagen.",
|
||||
"trail-merge-summary": "Verbleibend {remaining} - Verarbeitet {processed}/{total}",
|
||||
"trail-merge-result-summary": "Verknüpft {success} - Fehler {errors}",
|
||||
"trail-merge-retry": "Merge erneut ausführen",
|
||||
"trail-merge-cancel": "Merge abbrechen",
|
||||
"trail-merge-unknown-error": "Unbekannter Merge-Fehler",
|
||||
"trail-merge-target": "Zielroute",
|
||||
"trail-merge-remember-settings": "Letzte Merge-Einstellungen merken",
|
||||
"trail-merge-remember-settings-short": "Einstellungen merken",
|
||||
"trail-merge-warnings-title": "Warnungen",
|
||||
"trail-merge-suggest-error": "Der Merge-Vorschlag konnte nicht geladen werden.",
|
||||
"trail-merge-no-editable-target": "Es ist keine bearbeitbare Zielroute verfügbar.",
|
||||
"trail-merge-no-similar-trails": "Es wurden keine ausreichend ähnlichen Routen gefunden.",
|
||||
"trail-merge-loading-targets": "Geeignete Zielroute wird ermittelt...",
|
||||
"trail-merge-loading-similar-trails": "Ähnliche Routen werden gesucht...",
|
||||
"trail-merge-similar-trails-found": "{n, plural, =1 {1 ähnliche Route gefunden.} other {{n} ähnliche Routen gefunden.}}",
|
||||
"similar-trails-maintenance-title": "Wiederholte Routen / Duplikate",
|
||||
"similar-trails-maintenance-description": "Hier werden Gruppen von Routen angezeigt, die dieselbe oder eine sehr ähnliche Strecke mehrfach abbilden. Du kannst jede Gruppe auf der Karte prüfen und anschließend zu einer Zielroute zusammenführen.",
|
||||
"similar-trails-scan": "Gruppen neu ermitteln",
|
||||
"similar-trails-loading": "Ähnliche Routengruppen werden ermittelt...",
|
||||
"similar-trails-empty": "Es wurden aktuell keine ähnlichen Routengruppen gefunden.",
|
||||
"similar-trails-group-size": "{n, plural, =1 {1 Route} other {{n} Routen}}",
|
||||
"similar-trails-suggested-target": "Vorgeschlagene Zielroute",
|
||||
"similar-trails-target": "Zielroute",
|
||||
"similar-trails-set-target": "Als Zielroute festlegen",
|
||||
"similar-trails-show-map": "Auf Karte anzeigen",
|
||||
"similar-trails-hide-map": "Karte ausblenden",
|
||||
"similar-trails-merge-group": "Routen verknüpfen",
|
||||
"similar-trails-open-trail": "Route öffnen",
|
||||
"similar-trails-map-loading": "Kartendaten werden geladen...",
|
||||
"similar-trails-map-title": "Gruppenansicht",
|
||||
"similar-trails-indirect-hint": "Nicht jedes Routenpaar dieser Gruppe erfüllt die Ähnlichkeitsanforderungen. Bitte vor dem Verknüpfen kurz auf der Karte prüfen, ob wirklich alle Routen zusammenpassen.",
|
||||
"similar-trails-reason-highest_summit_log_count": "Zielroute vorgeschlagen, weil sie die meisten Gipfelbuch-Einträge enthält.",
|
||||
"similar-trails-reason-most_external_references": "Zielroute vorgeschlagen, weil sie die meisten externen Referenzen enthält.",
|
||||
"similar-trails-reason-most_complete_content": "Zielroute vorgeschlagen, weil sie die vollständigsten Inhalte enthält.",
|
||||
"similar-trails-reason-most_central_geometry": "Zielroute vorgeschlagen, weil sie geometrisch am besten zur Gruppe passt.",
|
||||
"similar-trails-reason-oldest_trail": "Zielroute vorgeschlagen, weil sie die älteste Route der Gruppe ist.",
|
||||
"similar-trails-reason-deterministic_fallback": "Zielroute vorgeschlagen, weil kein stärkeres Unterscheidungsmerkmal gefunden wurde.",
|
||||
"trail-merge-reason-highest_summit_log_count": "Diese Route wird vorgeschlagen, weil sie die meisten Gipfelbuch-Einträge enthält.",
|
||||
"trail-merge-reason-most_external_references": "Diese Route wird vorgeschlagen, weil sie die meisten externen Referenzen enthält.",
|
||||
"trail-merge-reason-most_complete_content": "Diese Route wird vorgeschlagen, weil sie die vollständigsten Inhalte enthält.",
|
||||
"trail-merge-reason-most_central_geometry": "Diese Route wird vorgeschlagen, weil sie geometrisch am besten zu den anderen Routen passt.",
|
||||
"trail-merge-reason-oldest_trail": "Diese Route wird vorgeschlagen, weil sie die älteste Route in der Auswahl ist.",
|
||||
"trail-merge-reason-deterministic_fallback": "Diese Route wird als stabiler Fallback vorgeschlagen.",
|
||||
"trail-merge-reason-selected_trail": "Ausgewählte Route",
|
||||
"trail-merge-reason-no_geometry_match": "Es wurde keine passende Routengeometrie gefunden.",
|
||||
"trail-merge-warning-missing_geometry": "Die Routengeometrie konnte nicht verglichen werden, weil GPX-Daten fehlen.",
|
||||
"trail-merge-warning-startpoints_far_apart": "Die Startpunkte der Routen liegen weit auseinander.",
|
||||
"trail-merge-warning-endpoints_far_apart": "Die Endpunkte der Routen liegen weit auseinander.",
|
||||
"trail-merge-warning-geometry_differs": "Die Routengeometrie unterscheidet sich deutlich.",
|
||||
"trail_merge_auth_required": "Du musst angemeldet sein, um Routen zu verknüpfen.",
|
||||
"trail_merge_actor_not_found": "Dein Actor-Profil konnte für den Merge nicht geladen werden.",
|
||||
"trail_merge_invalid_request": "Die Merge-Anfrage ist ungültig.",
|
||||
"trail_merge_unknown_suggest_mode": "Der Modus für den Merge-Vorschlag ist ungültig.",
|
||||
"trail_merge_missing_actor": "Für den Merge konnte kein aktueller Actor ermittelt werden.",
|
||||
"trail_merge_missing_trail_id": "Für den Merge fehlt eine erforderliche Routen-ID.",
|
||||
"trail_merge_same_source_target": "Quell- und Zielroute müssen unterschiedlich sein.",
|
||||
"trail_merge_requires_multiple_trails": "Wähle mindestens zwei Routen zum Verknüpfen aus.",
|
||||
"trail_merge_missing_source_trail_id": "Für automatische Merge-Vorschläge ist eine Quellroute erforderlich.",
|
||||
"trail_merge_source_actor_mismatch": "Die gewählte Quellroute gehört nicht zum aktuellen Actor.",
|
||||
"trail_merge_source_not_found": "Die gewählte Quellroute konnte nicht gefunden werden.",
|
||||
"trail_merge_target_not_found": "Die gewählte Zielroute konnte nicht gefunden werden.",
|
||||
"trail_merge_not_allowed": "Du hast keine Berechtigung, diese Routen zu verknüpfen.",
|
||||
"trails-for-you": "Routen für dich",
|
||||
"tram-stop": "Tram Haltestelle",
|
||||
"unchanged": "unverändert",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"changelog": "Changelog",
|
||||
"chinese": "Chinese (simplified)",
|
||||
"clear-all": "Clear all",
|
||||
"dismiss": "Dismiss",
|
||||
"climbing": "Climbing",
|
||||
"close": "Close",
|
||||
"collapse-trail-list": "Collapse trail list",
|
||||
@@ -90,7 +91,11 @@
|
||||
"connect": "Connect",
|
||||
"continue": "Continue",
|
||||
"contribute": "Contribute",
|
||||
"copy-comments": "Copy comments",
|
||||
"copy-include-elements": "Elements to be copied",
|
||||
"copy-link": "Copy Link",
|
||||
"copy-summit-log-photos": "Copy summit log photos",
|
||||
"copy-summit-logs": "Copy summit logs",
|
||||
"create-new-list": "Create new list",
|
||||
"create-waypoint": "Create waypoint",
|
||||
"create-waypoint-anyway": "Create anyway",
|
||||
@@ -108,6 +113,7 @@
|
||||
"degrees": "Degrees",
|
||||
"delete": "Delete",
|
||||
"delete-account": "Delete Account",
|
||||
"delete-linked-trails": "Delete linked trails",
|
||||
"delete-list-confirm": "Do you really want to delete this list? The trails in the list will still be available.",
|
||||
"delete-summit-log-confirm": "Do you really want to delete this summit log? This action cannot be undone.",
|
||||
"delete-trail-confirm": "Do you really want to delete this trail? This action cannot be undone.",
|
||||
@@ -158,6 +164,7 @@
|
||||
"error-liking-trail": "Error liking trail",
|
||||
"error-logging-in-to-hammerhead": "Error logging in to Hammerhead",
|
||||
"error-logging-in-to-komoot": "Error logging in to komoot",
|
||||
"error-merging-trail": "Error linking trails",
|
||||
"error-posting-comment": "Error posting comment",
|
||||
"error-printing-map": "Error printing map",
|
||||
"error-reading-file": "Error reading file",
|
||||
@@ -186,6 +193,7 @@
|
||||
"filter-categories": "Filter categories",
|
||||
"filter-difficulty": "Filter difficulty",
|
||||
"filter-tags": "Filter tags",
|
||||
"find-similar-trails": "Merge with similar trail",
|
||||
"finish": "Finish",
|
||||
"fixed-speed": "Fixed Speed",
|
||||
"focus-map-on": "Focus map on",
|
||||
@@ -233,6 +241,8 @@
|
||||
"integration-description-hammerhead": "Syncs your Hammerhead tours with wanderer in regular intervals.",
|
||||
"integration-description-komoot": "Syncs your komoot tours with wanderer in regular intervals.",
|
||||
"integration-description-strava": "Syncs your Strava routes & activities with wanderer in regular intervals.",
|
||||
"integration-auto-merge-label": "Auto-merge",
|
||||
"integration-auto-merge-hint": "Imported trails are only merged automatically when exactly one clear match is found. The imported trail is always deleted afterwards.",
|
||||
"integration-disabled": "integration disabled",
|
||||
"integration-enabled": "integration enabled",
|
||||
"integration-privacy-hint-original": "Imported trails will maintain the same visibility they have on the external platform. For example, if the original trail was public, it will be public in wanderer, even if trails are private by default according to your privacy settings.",
|
||||
@@ -253,8 +263,12 @@
|
||||
"liked": "Liked",
|
||||
"likes": "Likes",
|
||||
"limited": "Limited",
|
||||
"link": "Link",
|
||||
"link-as-summit-log": "Link as summit log",
|
||||
"link-copied": "Link copied!",
|
||||
"link-trails": "Link trails",
|
||||
"linked-lists": "Linked lists",
|
||||
"linked-trails": "Linked trails",
|
||||
"list": "{n, plural, =1 {List} other {Lists}}",
|
||||
"list-not-shared": "Not shared with anyone",
|
||||
"list-public-warning": "All trails in this list will become public.",
|
||||
@@ -427,6 +441,7 @@
|
||||
"show-in-overview": "Show in overview",
|
||||
"show-less": "Show less",
|
||||
"show-on-map": "Show on map",
|
||||
"similar-trails": "Repeated trails / duplicates",
|
||||
"shower": "Shower",
|
||||
"skiing": "Skiing",
|
||||
"slogan": "Save your adventures!",
|
||||
@@ -443,6 +458,8 @@
|
||||
"subway-stop": "Subway entrance",
|
||||
"summit": "Summit",
|
||||
"summit-book": "Summit Book",
|
||||
"summit-log": "{n, plural, =1 {Summit log} other {Summit logs}}",
|
||||
"summit-log-photos": "Summit log photos",
|
||||
"table": "Table",
|
||||
"tags": "Tags",
|
||||
"text": "Text",
|
||||
@@ -456,6 +473,70 @@
|
||||
"trail-not-in-list": "Trail is not in any list",
|
||||
"trail-not-shared": "Not shared with anyone",
|
||||
"trail-saved-successfully": "Trail saved successfully",
|
||||
"trail-merge-complete": "{success} trail links completed.",
|
||||
"trail-merge-complete-with-errors": "{success} trail links completed, {errors} failed.",
|
||||
"trail-merge-summary": "Remaining {remaining} - Processed {processed}/{total}",
|
||||
"trail-merge-result-summary": "Merged {success} - Error {errors}",
|
||||
"trail-merge-retry": "Retry merge",
|
||||
"trail-merge-cancel": "Cancel merge",
|
||||
"trail-merge-unknown-error": "Unknown merge error",
|
||||
"trail-merge-target": "Merge target",
|
||||
"trail-merge-remember-settings": "Remember last merge settings",
|
||||
"trail-merge-remember-settings-short": "Remember settings",
|
||||
"trail-merge-warnings-title": "Warnings",
|
||||
"trail-merge-suggest-error": "Failed to load merge suggestion.",
|
||||
"trail-merge-no-editable-target": "No editable merge target is available.",
|
||||
"trail-merge-no-similar-trails": "No sufficiently similar trails were found.",
|
||||
"trail-merge-loading-targets": "Finding a suitable target trail...",
|
||||
"trail-merge-loading-similar-trails": "Searching for similar trails...",
|
||||
"trail-merge-similar-trails-found": "{n, plural, =1 {1 similar trail found.} other {{n} similar trails found.}}",
|
||||
"similar-trails-maintenance-title": "Repeated trails / duplicates",
|
||||
"similar-trails-maintenance-description": "This page shows groups of trails that represent the same or a very similar route multiple times. Review each group on the map and merge it into a single target trail when appropriate.",
|
||||
"similar-trails-scan": "Refresh groups",
|
||||
"similar-trails-loading": "Finding groups of similar trails...",
|
||||
"similar-trails-empty": "No groups of similar trails were found right now.",
|
||||
"similar-trails-group-size": "{n, plural, =1 {1 trail} other {{n} trails}}",
|
||||
"similar-trails-suggested-target": "Suggested target trail",
|
||||
"similar-trails-target": "Target trail",
|
||||
"similar-trails-set-target": "Set as target trail",
|
||||
"similar-trails-show-map": "Show on map",
|
||||
"similar-trails-hide-map": "Hide map",
|
||||
"similar-trails-merge-group": "Merge trails",
|
||||
"similar-trails-open-trail": "Open trail",
|
||||
"similar-trails-map-loading": "Loading map data...",
|
||||
"similar-trails-map-title": "Group map",
|
||||
"similar-trails-indirect-hint": "Not every trail pair in this group meets the similarity requirements. Please check the map before merging to confirm that all trails really belong together.",
|
||||
"similar-trails-reason-highest_summit_log_count": "Suggested as the target trail because it has the most summit logs.",
|
||||
"similar-trails-reason-most_external_references": "Suggested as the target trail because it has the most external references.",
|
||||
"similar-trails-reason-most_complete_content": "Suggested as the target trail because it contains the richest content.",
|
||||
"similar-trails-reason-most_central_geometry": "Suggested as the target trail because it is geometrically the best fit within the group.",
|
||||
"similar-trails-reason-oldest_trail": "Suggested as the target trail because it is the oldest trail in the group.",
|
||||
"similar-trails-reason-deterministic_fallback": "Suggested as the target trail because no stronger distinguishing signal was found.",
|
||||
"trail-merge-reason-highest_summit_log_count": "This trail is suggested because it has the most summit logs.",
|
||||
"trail-merge-reason-most_external_references": "This trail is suggested because it has the most external references.",
|
||||
"trail-merge-reason-most_complete_content": "This trail is suggested because it contains the richest content.",
|
||||
"trail-merge-reason-most_central_geometry": "This trail is suggested because it is geometrically the best fit among the selected trails.",
|
||||
"trail-merge-reason-oldest_trail": "This trail is suggested because it is the oldest trail in the selection.",
|
||||
"trail-merge-reason-deterministic_fallback": "This trail is suggested as a stable fallback.",
|
||||
"trail-merge-reason-selected_trail": "Selected trail",
|
||||
"trail-merge-reason-no_geometry_match": "No suitable trail geometry match was found.",
|
||||
"trail-merge-warning-missing_geometry": "Could not compare trail geometry because GPX data is missing.",
|
||||
"trail-merge-warning-startpoints_far_apart": "Trail start points are far apart.",
|
||||
"trail-merge-warning-endpoints_far_apart": "Trail end points are far apart.",
|
||||
"trail-merge-warning-geometry_differs": "Trail geometry differs noticeably.",
|
||||
"trail_merge_auth_required": "You need to be signed in to merge trails.",
|
||||
"trail_merge_actor_not_found": "Your actor profile could not be loaded for the merge.",
|
||||
"trail_merge_invalid_request": "The merge request is invalid.",
|
||||
"trail_merge_unknown_suggest_mode": "The merge suggestion mode is invalid.",
|
||||
"trail_merge_missing_actor": "The merge could not determine the current actor.",
|
||||
"trail_merge_missing_trail_id": "The merge is missing a required trail id.",
|
||||
"trail_merge_same_source_target": "Source and target trail must be different.",
|
||||
"trail_merge_requires_multiple_trails": "Select at least two trails to merge.",
|
||||
"trail_merge_missing_source_trail_id": "A source trail is required for automatic merge suggestions.",
|
||||
"trail_merge_source_actor_mismatch": "The selected source trail does not belong to the current actor.",
|
||||
"trail_merge_source_not_found": "The selected source trail could not be found.",
|
||||
"trail_merge_target_not_found": "The selected target trail could not be found.",
|
||||
"trail_merge_not_allowed": "You do not have permission to merge these trails.",
|
||||
"trails-for-you": "Trails for you",
|
||||
"tram-stop": "Tram stop",
|
||||
"unchanged": "unchanged",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { z, ZodType } from "zod";
|
||||
import type { Integration } from "../integration";
|
||||
|
||||
const IntegrationMergeSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
|
||||
const StravaSchema = z.object({
|
||||
clientId: z.number({ coerce: true }).int().nonnegative(),
|
||||
clientSecret: z.string().length(40).optional().or(z.literal('')),
|
||||
@@ -8,7 +12,8 @@ const StravaSchema = z.object({
|
||||
activities: z.boolean(),
|
||||
active: z.boolean(),
|
||||
after: z.string().date().optional(),
|
||||
privacy: z.enum(["original", "settings"])
|
||||
privacy: z.enum(["original", "settings"]),
|
||||
merge: IntegrationMergeSchema,
|
||||
})
|
||||
|
||||
const KomootSchema = z.object({
|
||||
@@ -17,7 +22,8 @@ const KomootSchema = z.object({
|
||||
completed: z.boolean(),
|
||||
planned: z.boolean(),
|
||||
active: z.boolean(),
|
||||
privacy: z.enum(["original", "settings"])
|
||||
privacy: z.enum(["original", "settings"]),
|
||||
merge: IntegrationMergeSchema,
|
||||
})
|
||||
|
||||
const HammerheadSchema = z.object({
|
||||
@@ -27,6 +33,7 @@ const HammerheadSchema = z.object({
|
||||
planned: z.boolean(),
|
||||
active: z.boolean(),
|
||||
after: z.string().date().optional(),
|
||||
merge: IntegrationMergeSchema,
|
||||
})
|
||||
|
||||
const IntegrationCreateSchema = z.object({
|
||||
|
||||
@@ -1193,6 +1193,146 @@
|
||||
* enum: [public, private]
|
||||
* nullable: true
|
||||
*
|
||||
* TrailMergeSettings:
|
||||
* type: object
|
||||
* required:
|
||||
* - summitLog
|
||||
* - photos
|
||||
* - comments
|
||||
* - delete
|
||||
* - tags
|
||||
* - likes
|
||||
* properties:
|
||||
* summitLog:
|
||||
* type: boolean
|
||||
* photos:
|
||||
* type: boolean
|
||||
* comments:
|
||||
* type: boolean
|
||||
* delete:
|
||||
* type: boolean
|
||||
* tags:
|
||||
* type: boolean
|
||||
* likes:
|
||||
* type: boolean
|
||||
*
|
||||
* TrailMergeSuggestRequest:
|
||||
* type: object
|
||||
* required:
|
||||
* - mode
|
||||
* properties:
|
||||
* mode:
|
||||
* type: string
|
||||
* enum: [manual-selection, auto-discovery, maintenance-groups]
|
||||
* trailIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: Required for manual-selection
|
||||
* sourceTrailId:
|
||||
* type: string
|
||||
* description: Required for auto-discovery
|
||||
*
|
||||
* TrailMergeSuggestCandidate:
|
||||
* type: object
|
||||
* required:
|
||||
* - trailId
|
||||
* - score
|
||||
* - reason
|
||||
* - warnings
|
||||
* - selectable
|
||||
* properties:
|
||||
* trailId:
|
||||
* type: string
|
||||
* score:
|
||||
* type: number
|
||||
* reason:
|
||||
* type: string
|
||||
* warnings:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* selectable:
|
||||
* type: boolean
|
||||
*
|
||||
* TrailMergeSuggestResponse:
|
||||
* type: object
|
||||
* required:
|
||||
* - targetTrailId
|
||||
* - reason
|
||||
* - warnings
|
||||
* - candidates
|
||||
* properties:
|
||||
* targetTrailId:
|
||||
* type: string
|
||||
* reason:
|
||||
* type: string
|
||||
* warnings:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* candidates:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/TrailMergeSuggestCandidate'
|
||||
*
|
||||
* TrailMergeSuggestGroup:
|
||||
* type: object
|
||||
* required:
|
||||
* - groupId
|
||||
* - trailIds
|
||||
* - targetTrailId
|
||||
* - reason
|
||||
* - score
|
||||
* - indirect
|
||||
* properties:
|
||||
* groupId:
|
||||
* type: string
|
||||
* trailIds:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* targetTrailId:
|
||||
* type: string
|
||||
* reason:
|
||||
* type: string
|
||||
* score:
|
||||
* type: number
|
||||
* indirect:
|
||||
* type: boolean
|
||||
*
|
||||
* TrailMergeSuggestGroupsResponse:
|
||||
* type: object
|
||||
* required:
|
||||
* - groups
|
||||
* properties:
|
||||
* groups:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/TrailMergeSuggestGroup'
|
||||
*
|
||||
* TrailMergeExecuteRequest:
|
||||
* type: object
|
||||
* required:
|
||||
* - sourceTrailId
|
||||
* - targetTrailId
|
||||
* - settings
|
||||
* properties:
|
||||
* sourceTrailId:
|
||||
* type: string
|
||||
* targetTrailId:
|
||||
* type: string
|
||||
* settings:
|
||||
* $ref: '#/components/schemas/TrailMergeSettings'
|
||||
*
|
||||
* TrailMergeExecuteResponse:
|
||||
* type: object
|
||||
* required:
|
||||
* - acknowledged
|
||||
* properties:
|
||||
* acknowledged:
|
||||
* type: boolean
|
||||
*
|
||||
* ListResult:
|
||||
* type: object
|
||||
* properties:
|
||||
|
||||
@@ -3,6 +3,10 @@ export interface BaseIntegration {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface IntegrationMergeSettings {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface StravaIntegration extends BaseIntegration {
|
||||
clientId: string | number;
|
||||
clientSecret?: string;
|
||||
@@ -13,6 +17,7 @@ export interface StravaIntegration extends BaseIntegration {
|
||||
expiresAt?: number;
|
||||
after?: string
|
||||
privacy: "original" | "settings"
|
||||
merge: IntegrationMergeSettings;
|
||||
}
|
||||
|
||||
export interface KomootIntegration extends BaseIntegration {
|
||||
@@ -21,6 +26,7 @@ export interface KomootIntegration extends BaseIntegration {
|
||||
completed: boolean,
|
||||
planned: boolean
|
||||
privacy: "original" | "settings"
|
||||
merge: IntegrationMergeSettings;
|
||||
}
|
||||
|
||||
export interface HammerheadIntegration extends BaseIntegration {
|
||||
@@ -29,6 +35,7 @@ export interface HammerheadIntegration extends BaseIntegration {
|
||||
completed: boolean,
|
||||
planned: boolean,
|
||||
after?: string
|
||||
merge: IntegrationMergeSettings;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,4 +52,4 @@ export class Integration {
|
||||
this.komoot = komoot;
|
||||
this.hammerhead = hammerhead;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
57
web/src/routes/api/v1/trail-merge/+server.ts
Normal file
57
web/src/routes/api/v1/trail-merge/+server.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/trail-merge:
|
||||
* post:
|
||||
* summary: Merge trails
|
||||
* description: Merges a source trail into a target trail using the backend merge logic.
|
||||
* tags:
|
||||
* - Trail Merge
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TrailMergeExecuteRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Merge acknowledged
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TrailMergeExecuteResponse'
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: Forbidden
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const body = await event.request.json();
|
||||
const response = await event.locals.pb.send("/trail-merge", {
|
||||
method: "POST",
|
||||
body,
|
||||
fetch: event.fetch,
|
||||
});
|
||||
|
||||
return json(response);
|
||||
} catch (e: any) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
53
web/src/routes/api/v1/trail-merge/suggest/+server.ts
Normal file
53
web/src/routes/api/v1/trail-merge/suggest/+server.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { handleError } from "$lib/util/api_util";
|
||||
import { json, type RequestEvent } from "@sveltejs/kit";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/trail-merge/suggest:
|
||||
* post:
|
||||
* summary: Suggest merge target or duplicate groups
|
||||
* description: Returns merge target suggestions for manual selection or auto-discovery, or temporary duplicate groups for maintenance workflows.
|
||||
* tags:
|
||||
* - Trail Merge
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/TrailMergeSuggestRequest'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Suggestion response
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* oneOf:
|
||||
* - $ref: '#/components/schemas/TrailMergeSuggestResponse'
|
||||
* - $ref: '#/components/schemas/TrailMergeSuggestGroupsResponse'
|
||||
* 400:
|
||||
* description: Bad Request
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export async function POST(event: RequestEvent) {
|
||||
try {
|
||||
const body = await event.request.json();
|
||||
const response = await event.locals.pb.send("/trail-merge/suggest", {
|
||||
method: "POST",
|
||||
body,
|
||||
fetch: event.fetch,
|
||||
});
|
||||
|
||||
return json(response);
|
||||
} catch (e: any) {
|
||||
return handleError(e);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
{ text: $_("notifications"), value: "/settings/notifications" },
|
||||
{ text: $_("map"), value: "/settings/map" },
|
||||
{ text: $_("integrations"), value: "/settings/integrations" },
|
||||
{ text: $_("similar-trails"), value: "/settings/maintenance/similar-trails" },
|
||||
{ text: `${$_("import")}/${$_("export")}`, value: "/settings/export" },
|
||||
{
|
||||
text: $_("help"),
|
||||
|
||||
@@ -105,6 +105,7 @@
|
||||
routes: integration.strava.routes,
|
||||
activities: integration.strava.activities,
|
||||
privacy: integration.strava.privacy,
|
||||
merge: integration.strava.merge,
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
expiresAt: undefined,
|
||||
|
||||
356
web/src/routes/settings/maintenance/similar-trails/+page.svelte
Normal file
356
web/src/routes/settings/maintenance/similar-trails/+page.svelte
Normal file
@@ -0,0 +1,356 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import MergeDialog from "$lib/components/trail/trail_merge_dialog.svelte";
|
||||
import TrailListItem from "$lib/components/trail/trail_list_item.svelte";
|
||||
import TrailMergeModal, {
|
||||
type MergeSelection,
|
||||
type MergeSettings,
|
||||
} from "$lib/components/trail/trail_merge_modal.svelte";
|
||||
import MapWithElevationMaplibre from "$lib/components/trail/map_with_elevation_maplibre.svelte";
|
||||
import type { Trail } from "$lib/models/trail";
|
||||
import {
|
||||
type TrailMergeSuggestGroup,
|
||||
trail_merge,
|
||||
trail_merge_suggest_groups,
|
||||
} from "$lib/stores/trail_merge_api";
|
||||
import { translateTrailMergeError } from "$lib/stores/trail_merge_i18n";
|
||||
import {
|
||||
mergeStore,
|
||||
processMergeQueue,
|
||||
type Merge,
|
||||
} from "$lib/stores/trail_merge_store.svelte";
|
||||
import { trails_show } from "$lib/stores/trail_store";
|
||||
import { handleFromRecordWithIRI } from "$lib/util/activitypub_util";
|
||||
import { APIError } from "$lib/util/api_util";
|
||||
import { _ } from "svelte-i18n";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
type SimilarTrailGroupView = TrailMergeSuggestGroup & {
|
||||
trails: Trail[];
|
||||
targetTrail?: Trail;
|
||||
suggestedTargetTrailId: string;
|
||||
};
|
||||
|
||||
let loading = $state(true);
|
||||
let groups = $state<SimilarTrailGroupView[]>([]);
|
||||
let loadError = $state("");
|
||||
let openMapGroupId = $state<string | null>(null);
|
||||
let mapLoadingGroupId = $state<string | null>(null);
|
||||
let mapTrailsByGroupId = $state<Record<string, Trail[]>>({});
|
||||
let trailById = $state<Record<string, Trail>>({});
|
||||
let trailWithGpxById = $state<Record<string, Trail>>({});
|
||||
|
||||
let trailMergeModal: TrailMergeModal;
|
||||
|
||||
onMount(async () => {
|
||||
await loadGroups();
|
||||
});
|
||||
|
||||
async function loadGroups() {
|
||||
loading = true;
|
||||
loadError = "";
|
||||
openMapGroupId = null;
|
||||
|
||||
try {
|
||||
const response = await trail_merge_suggest_groups();
|
||||
const uniqueTrailIds = Array.from(
|
||||
new Set(response.groups.flatMap((group) => group.trailIds)),
|
||||
);
|
||||
const missingTrailIds = uniqueTrailIds.filter((trailId) => !trailById[trailId]);
|
||||
|
||||
if (missingTrailIds.length > 0) {
|
||||
const loadedTrails = await Promise.all(
|
||||
missingTrailIds.map((trailId) => trails_show(trailId)),
|
||||
);
|
||||
|
||||
trailById = {
|
||||
...trailById,
|
||||
...Object.fromEntries(
|
||||
loadedTrails
|
||||
.filter((trail) => Boolean(trail.id))
|
||||
.map((trail) => [trail.id!, trail]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
groups = await Promise.all(
|
||||
response.groups.map(async (group) => {
|
||||
const trails = group.trailIds
|
||||
.map((trailId) => trailById[trailId])
|
||||
.filter((trail): trail is Trail => Boolean(trail));
|
||||
|
||||
return {
|
||||
...group,
|
||||
trails,
|
||||
targetTrail: trails.find((trail) => trail.id === group.targetTrailId),
|
||||
suggestedTargetTrailId: group.targetTrailId,
|
||||
} satisfies SimilarTrailGroupView;
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to load similar trail groups", error);
|
||||
loadError =
|
||||
error instanceof APIError
|
||||
? translateTrailMergeError(error.message)
|
||||
: $_("trail-merge-unknown-error");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMap(group: SimilarTrailGroupView) {
|
||||
if (openMapGroupId === group.groupId) {
|
||||
openMapGroupId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
openMapGroupId = group.groupId;
|
||||
if (mapTrailsByGroupId[group.groupId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapLoadingGroupId = group.groupId;
|
||||
try {
|
||||
const trailIdsToLoad = group.trails
|
||||
.map((trail) => trail.id)
|
||||
.filter((trailId): trailId is string => Boolean(trailId))
|
||||
.filter((trailId) => !trailWithGpxById[trailId]);
|
||||
|
||||
if (trailIdsToLoad.length > 0) {
|
||||
const loadedTrails = await Promise.all(
|
||||
trailIdsToLoad.map((trailId) =>
|
||||
trails_show(trailId, undefined, undefined, true),
|
||||
),
|
||||
);
|
||||
|
||||
trailWithGpxById = {
|
||||
...trailWithGpxById,
|
||||
...Object.fromEntries(
|
||||
loadedTrails
|
||||
.filter((trail) => Boolean(trail.id))
|
||||
.map((trail) => [trail.id!, trail]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const groupMapTrails = group.trails
|
||||
.map((trail) => trail.id)
|
||||
.filter((trailId): trailId is string => Boolean(trailId))
|
||||
.map((trailId) => trailWithGpxById[trailId])
|
||||
.filter((trail): trail is Trail => Boolean(trail));
|
||||
|
||||
mapTrailsByGroupId = {
|
||||
...mapTrailsByGroupId,
|
||||
[group.groupId]: groupMapTrails,
|
||||
};
|
||||
} finally {
|
||||
mapLoadingGroupId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function openMergeGroupModal(group: SimilarTrailGroupView) {
|
||||
await trailMergeModal.openModal(group.trails, {
|
||||
fixedTargetTrailId: group.targetTrailId,
|
||||
});
|
||||
}
|
||||
|
||||
function updateGroupTarget(groupId: string, targetTrailId: string) {
|
||||
groups = groups.map((group) => {
|
||||
if (group.groupId !== groupId) {
|
||||
return group;
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
targetTrailId,
|
||||
targetTrail: group.trails.find((trail) => trail.id === targetTrailId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function mergeGroup(settings: MergeSettings, selection: MergeSelection) {
|
||||
let trailTarget = selection.targetTrail;
|
||||
if (!trailTarget.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trailTarget.expand) {
|
||||
trailTarget = await trails_show(trailTarget.id);
|
||||
}
|
||||
|
||||
for (const sourceTrail of selection.sourceTrails) {
|
||||
if (sourceTrail.id === trailTarget.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mergeJob: Merge = {
|
||||
trailTarget,
|
||||
trailSource: sourceTrail,
|
||||
progress: 0,
|
||||
status: "enqueued",
|
||||
settings,
|
||||
function: async (target, source, mergeSettings, onProgress) => {
|
||||
if (!target.id || !source.id) {
|
||||
throw new Error($_("error-merging-trail"));
|
||||
}
|
||||
|
||||
onProgress?.(0.2);
|
||||
await trail_merge(source.id, target.id, mergeSettings);
|
||||
onProgress?.(1);
|
||||
},
|
||||
};
|
||||
|
||||
mergeStore.enqueuedMerges.push(mergeJob);
|
||||
}
|
||||
|
||||
const completedBeforeRun = mergeStore.completedMerges.length;
|
||||
await processMergeQueue();
|
||||
const completedThisRun = mergeStore.completedMerges.slice(completedBeforeRun);
|
||||
const successfulThisRun = completedThisRun.filter(
|
||||
(merge) => merge.status === "success",
|
||||
).length;
|
||||
|
||||
if (successfulThisRun > 0 && settings.delete) {
|
||||
await loadGroups();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_("similar-trails-maintenance-title")} | wanderer</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-3xl font-bold">{$_("similar-trails-maintenance-title")}</h1>
|
||||
<p class="text-sm text-gray-500 max-w-3xl">
|
||||
{$_("similar-trails-maintenance-description")}
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-secondary shrink-0" onclick={loadGroups} disabled={loading}>
|
||||
{$_("similar-trails-scan")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="rounded-2xl border border-input-border p-6 flex items-center gap-3">
|
||||
<div class="spinner light:spinner-dark"></div>
|
||||
<p class="text-sm text-gray-500">{$_("similar-trails-loading")}</p>
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<div class="rounded-2xl border border-red-500/40 bg-red-500/10 p-4 text-sm text-red-300">
|
||||
{loadError}
|
||||
</div>
|
||||
{:else if groups.length === 0}
|
||||
<div class="rounded-2xl border border-input-border p-6 text-sm text-gray-500">
|
||||
{$_("similar-trails-empty")}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-6">
|
||||
{#each groups as group}
|
||||
<section class="rounded-2xl border border-input-border overflow-hidden">
|
||||
<div class="p-5 flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="text-xl font-semibold">
|
||||
{$_("similar-trails-group-size", {
|
||||
values: { n: group.trails.length },
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
{#if group.targetTrailId === group.suggestedTargetTrailId}
|
||||
<p class="text-sm text-gray-500">
|
||||
{$_(`similar-trails-reason-${group.reason}`)}
|
||||
</p>
|
||||
{/if}
|
||||
{#if group.indirect}
|
||||
<div class="rounded-xl border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-800 dark:text-yellow-200">
|
||||
{$_("similar-trails-indirect-hint")}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-3 shrink-0">
|
||||
<button class="btn-secondary" onclick={() => toggleMap(group)}>
|
||||
{openMapGroupId === group.groupId
|
||||
? $_("similar-trails-hide-map")
|
||||
: $_("similar-trails-show-map")}
|
||||
</button>
|
||||
<button class="btn-primary" onclick={() => openMergeGroupModal(group)}>
|
||||
{$_("similar-trails-merge-group")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if openMapGroupId === group.groupId}
|
||||
<div class="border-t border-input-border p-5 space-y-3">
|
||||
<h3 class="text-lg font-semibold">{$_("similar-trails-map-title")}</h3>
|
||||
{#if mapLoadingGroupId === group.groupId}
|
||||
<div class="flex items-center gap-3 py-6">
|
||||
<div class="spinner light:spinner-dark"></div>
|
||||
<p class="text-sm text-gray-500">{$_("similar-trails-map-loading")}</p>
|
||||
</div>
|
||||
{:else if mapTrailsByGroupId[group.groupId]}
|
||||
<div class="h-[26rem] rounded-2xl overflow-hidden border border-input-border">
|
||||
<MapWithElevationMaplibre
|
||||
trails={mapTrailsByGroupId[group.groupId]}
|
||||
showTerrain={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each group.trails as trail}
|
||||
<div class="relative group">
|
||||
<a
|
||||
class="block"
|
||||
href={`/trail/view/${handleFromRecordWithIRI(trail)}/${trail.id}`}
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
goto(`/trail/view/${handleFromRecordWithIRI(trail)}/${trail.id}`);
|
||||
}}
|
||||
>
|
||||
<TrailListItem
|
||||
{trail}
|
||||
selected={false}
|
||||
hovered={false}
|
||||
showDescription={false}
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class={`absolute bottom-4 right-4 z-10 flex h-9 w-9 items-center justify-center rounded-full border shadow-sm transition-all ${
|
||||
trail.id === group.targetTrailId
|
||||
? "bg-primary text-white border-primary opacity-100"
|
||||
: "bg-background/95 text-gray-500 border-input-border opacity-0 group-hover:opacity-100 hover:border-primary hover:text-primary"
|
||||
}`}
|
||||
onclick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (trail.id) {
|
||||
updateGroupTarget(group.groupId, trail.id);
|
||||
}
|
||||
}}
|
||||
aria-label={$_("similar-trails-set-target")}
|
||||
title={$_("similar-trails-set-target")}
|
||||
>
|
||||
<i class="fa fa-flag-checkered"></i>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<TrailMergeModal
|
||||
bind:this={trailMergeModal}
|
||||
onmerge={(settings, selection) => mergeGroup(settings, selection)}
|
||||
/>
|
||||
<MergeDialog />
|
||||
@@ -764,6 +764,125 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trail-merge": {
|
||||
"post": {
|
||||
"summary": "Merge trails",
|
||||
"description": "Merges a source trail into a target trail using the backend merge logic.",
|
||||
"tags": [
|
||||
"Trail Merge"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TrailMergeExecuteRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Merge acknowledged",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TrailMergeExecuteResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trail-merge/suggest": {
|
||||
"post": {
|
||||
"summary": "Suggest merge target or duplicate groups",
|
||||
"description": "Returns merge target suggestions for manual selection or auto-discovery, or temporary duplicate groups for maintenance workflows.",
|
||||
"tags": [
|
||||
"Trail Merge"
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TrailMergeSuggestRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Suggestion response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TrailMergeSuggestResponse"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/TrailMergeSuggestGroupsResponse"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trail-link-share": {
|
||||
"get": {
|
||||
"summary": "List trail link shares",
|
||||
@@ -6571,6 +6690,201 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrailMergeSettings": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"summitLog",
|
||||
"photos",
|
||||
"comments",
|
||||
"delete",
|
||||
"tags",
|
||||
"likes"
|
||||
],
|
||||
"properties": {
|
||||
"summitLog": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"photos": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"comments": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"delete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tags": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"likes": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrailMergeSuggestRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"mode"
|
||||
],
|
||||
"properties": {
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"manual-selection",
|
||||
"auto-discovery",
|
||||
"maintenance-groups"
|
||||
]
|
||||
},
|
||||
"trailIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Required for manual-selection"
|
||||
},
|
||||
"sourceTrailId": {
|
||||
"type": "string",
|
||||
"description": "Required for auto-discovery"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrailMergeSuggestCandidate": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"trailId",
|
||||
"score",
|
||||
"reason",
|
||||
"warnings",
|
||||
"selectable"
|
||||
],
|
||||
"properties": {
|
||||
"trailId": {
|
||||
"type": "string"
|
||||
},
|
||||
"score": {
|
||||
"type": "number"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"selectable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrailMergeSuggestResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"targetTrailId",
|
||||
"reason",
|
||||
"warnings",
|
||||
"candidates"
|
||||
],
|
||||
"properties": {
|
||||
"targetTrailId": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"candidates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TrailMergeSuggestCandidate"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrailMergeSuggestGroup": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"groupId",
|
||||
"trailIds",
|
||||
"targetTrailId",
|
||||
"reason",
|
||||
"score",
|
||||
"indirect"
|
||||
],
|
||||
"properties": {
|
||||
"groupId": {
|
||||
"type": "string"
|
||||
},
|
||||
"trailIds": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"targetTrailId": {
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"type": "string"
|
||||
},
|
||||
"score": {
|
||||
"type": "number"
|
||||
},
|
||||
"indirect": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrailMergeSuggestGroupsResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"groups"
|
||||
],
|
||||
"properties": {
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TrailMergeSuggestGroup"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrailMergeExecuteRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"sourceTrailId",
|
||||
"targetTrailId",
|
||||
"settings"
|
||||
],
|
||||
"properties": {
|
||||
"sourceTrailId": {
|
||||
"type": "string"
|
||||
},
|
||||
"targetTrailId": {
|
||||
"type": "string"
|
||||
},
|
||||
"settings": {
|
||||
"$ref": "#/components/schemas/TrailMergeSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"TrailMergeExecuteResponse": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"acknowledged"
|
||||
],
|
||||
"properties": {
|
||||
"acknowledged": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ListResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user