Federation (#327)

* initial commit federation

* more federation

* more federation

* more federation

* completes follow, accept, undo

* add trail create activity

* process trail create activity

* more trail create activity

* adds missing endpoints

* adds update and delete activity

* adds comment activities

* adds activities back

* adds summit logs activities

* deletes follow counts table

* fixes migrations

* fixes migrations

* fixes migrations

* fixes migrations

* adds remote profiles

* ctd

* ctd

* we are getting closer...

* adds public summit logs

* fixes federated trails in lists

* adds remote lists

* adds list activites

* fixes lists

* adds notifications

* adds iri redirect

* fixes comments and summitlogs

* fixes follows and profiles

* adds asynchronous send

* fixes list search

* adds encryption key

* bug fixes

* fixes activity signing

* removes custon activity object types

* adds html editor

* fixes html editor

* fixes display issues on mastodon

* adds federated sharing

* finishes announcements

* fixes small summit log issues

* adds trail likes

* finalizes likes

* fixes images for komoot

* add disable federation option

* adds private profiles

* updates docs

* adds federated comments

* adds trail and comments actvitiypub routes

* adds mentions to editor

* adds mentions to trails, comments, summit logs

* updates theme

* updates theme

* update docs

* update docs

* updates docs

* fixes various frontend problems

* fixes activitypub follows api

* updates docs

* updates docs

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
Flomp
2025-06-21 10:09:22 +02:00
committed by GitHub
parent 742a6d9d30
commit 9276690d60
356 changed files with 57150 additions and 35470 deletions

View File

@@ -1,30 +0,0 @@
import type { Activity } from "$lib/models/activity";
import { APIError } from "$lib/util/api_util";
import { type ListResult } from "pocketbase";
let activities: Activity[] = [];
export async function activities_index(author: string, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/activity?' + new URLSearchParams({
"perPage": perPage.toString(),
page: page.toString(),
filter: `author="${author}"`,
sort: "-created"
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const fetchedActivities: ListResult<Activity> = await r.json();
const result = page > 1 ? [...activities, ...fetchedActivities.items] : fetchedActivities.items
activities = result;
return { ...fetchedActivities, items: result };
}

View File

@@ -4,13 +4,22 @@ import { APIError } from "$lib/util/api_util";
import { type ListResult } from "pocketbase";
import { get, writable, type Writable } from "svelte/store";
import { currentUser } from "./user_store";
import { isURL } from "$lib/util/file_util";
export const comments: Writable<Comment[]> = writable([])
export async function comments_index(trail: Trail) {
let r = await fetch('/api/v1/comment?' + new URLSearchParams({
filter: `trail="${trail.id}"`,
sort: "-created"
export async function comments_index(trailId: string, handle?: string) {
let filter: string;
if (isURL(trailId)) {
filter = `trail="${trailId}"||trail.iri="${trailId}"||trail="${trailId.substring(trailId.length - 15)}"`
} else {
filter = `trail="${trailId}"`
}
let r = await fetch(`/api/v1/comment?` + new URLSearchParams({
filter,
expand: "author",
sort: "-created",
...(handle ? { handle } : {})
}), {
method: 'GET',
})
@@ -33,9 +42,11 @@ export async function comments_create(comment: Comment) {
throw Error("Unauthenticated")
}
comment.author = user.authStore.record!.id;
comment.author = user.actor
let r = await fetch('/api/v1/comment', {
let r = await fetch('/api/v1/comment?' + new URLSearchParams({
expand: "author",
}), {
method: 'PUT',
body: JSON.stringify(comment),
})

View File

@@ -1,15 +1,17 @@
import type { Actor } from "$lib/models/activitypub/actor";
import type { Follow } from "$lib/models/follow";
import { APIError } from "$lib/util/api_util";
import { type ListResult } from "pocketbase";
let follows: Follow[] = [];
let follows: Actor[] = [];
export async function follows_index(data: { follower?: string, followee?: string }, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/follow?' + new URLSearchParams({
filter: data.follower ? `follower='${data.follower}'` : data.followee ? `followee='${data.followee}'` : '',
export async function follows_index(data: { username: string, type: "followers" | "following" }, page: number = 1, perPage: number = 10, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f(`/api/v1/follow?` + new URLSearchParams({
handle: data.username,
type: data.type,
page: page.toString(),
perPage: perPage.toString(),
requestKey: "page"
}), {
method: 'GET',
})
@@ -19,11 +21,11 @@ export async function follows_index(data: { follower?: string, followee?: string
throw new APIError(r.status, response.message, response.detail)
}
const fetchedFollows: ListResult<Follow> = await r.json();
const fetchedFollows: ListResult<Actor> = await r.json();
const result = page > 1 ? [...follows, ...fetchedFollows.items] : fetchedFollows.items
follows = result;
follows = result;
return { ...fetchedFollows, items: result };
}
@@ -45,25 +47,10 @@ export async function follows_a_b(a: string, b: string, f: (url: RequestInfo | U
return response.items.at(0);
}
export async function follows_counts(id: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/follow/count/' + id, {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const response: { followers: number, following: number } = await r.json();
return response;
}
export async function follows_create(follow: Follow) {
export async function follows_create(followee: string) {
let r = await fetch('/api/v1/follow', {
method: 'PUT',
body: JSON.stringify(follow),
body: JSON.stringify({ followee }),
})
if (!r.ok) {

View File

@@ -9,6 +9,7 @@ export const shares: Writable<ListShare[]> = writable([])
export async function list_share_index(list: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/list-share?' + new URLSearchParams({
filter: `list='${list}'`,
expand: "actor"
}), {
method: 'GET',
})

View File

@@ -1,21 +1,22 @@
import { ExpandType, ExpandTypeToString, List, type ListFilter } from "$lib/models/list";
import { List, type ListFilter } from "$lib/models/list";
import type { Trail } from "$lib/models/trail";
import { APIError } from "$lib/util/api_util";
import type { Hits } from "meilisearch";
import { type AuthRecord, type ListResult } from "pocketbase";
import { type AuthRecord, type ListResult, type RecordModel } from "pocketbase";
import { get, writable, type Writable } from "svelte/store";
import type { ListSearchResult } from "./search_store";
import { fetchGPX } from "./trail_store";
import { currentUser } from "./user_store";
import { objectToFormData } from "$lib/util/file_util";
let lists: List[] = []
export const list: Writable<List | null> = writable(null)
export const listTrail: Writable<Trail | null> = writable(null);
export async function lists_index(filter?: ListFilter, page: number = 1, perPage: number = 5,
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch,
e: ExpandType = ExpandType.All) {
f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const user = get(currentUser)
const filterText = filter ? buildFilterText(user, filter) : ""
const r = await f('/api/v1/list?' + new URLSearchParams({
@@ -23,7 +24,6 @@ export async function lists_index(filter?: ListFilter, page: number = 1, perPage
perPage: perPage.toString(),
page: page.toString(),
filter: filterText,
expand: ExpandTypeToString(e),
}), {
method: 'GET',
})
@@ -43,8 +43,8 @@ export async function lists_index(filter?: ListFilter, page: number = 1, perPage
}
export async function lists_search_filter(filter: ListFilter, page: number = 1, perPage: number = 5, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<ListResult<List>> {
const user = get(currentUser)
export async function lists_search_filter(filter: ListFilter, page: number = 1, perPage: number = 5, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch, user?: AuthRecord) {
user ??= get(currentUser)
const filterText = buildSearchFilterText(user, filter)
@@ -65,43 +65,21 @@ export async function lists_search_filter(filter: ListFilter, page: number = 1,
throw new APIError(r.status, response.message, response.detail)
}
const searchResult: { page: number, totalPages: number, hits: Hits<Record<string, any>> } = await r.json();
const result: { page: number, totalPages: number, hits: Hits<ListSearchResult> } = await r.json();
const listIds = searchResult.hits.map((h: Record<string, any>) => h.id);
const resultLists: List[] = await searchResultToLists(result.hits)
if (listIds.length == 0) {
return { items: [], page: searchResult.page, perPage, totalItems: 0, totalPages: searchResult.totalPages };
}
return { items: resultLists, ...result };
r = await f('/api/v1/list?' + new URLSearchParams({
expand: "trails,trails.waypoints,trails.category,list_share_via_list",
filter: `'${listIds.join(',')}'~id`,
sort: filter.sort && filter.sortOrder ? `${filter.sortOrder}${filter.sort}` : ''
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const fetchedLists: ListResult<List> = await r.json();
const result = page > 1 ? [...lists, ...fetchedLists.items] : fetchedLists.items
lists = result;
return { ...fetchedLists, items: result };
}
export async function lists_show(id: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch
, e: ExpandType = ExpandType.All) {
export async function lists_show(id: string, handle?: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f(`/api/v1/list/${id}?` + new URLSearchParams({
expand: ExpandTypeToString(e)
expand: "author,trails,trails.author,list_share_via_list.actor",
...(handle ? { handle } : {})
}), {
method: 'GET',
})
@@ -118,9 +96,6 @@ export async function lists_show(id: string, f: (url: RequestInfo | URL, config?
trail.expand.gpx_data = gpxData;
}
list.set(response);
return response;
@@ -132,11 +107,17 @@ export async function lists_create(list: List, avatar?: File) {
if (!user) {
throw Error("Unauthenticated")
}
list.author = user.id;
list.author = user.actor;
let r = await fetch('/api/v1/list', {
const formData = objectToFormData(list)
if (avatar) {
formData.append("avatar", avatar);
}
let r = await fetch('/api/v1/list/form', {
method: 'PUT',
body: JSON.stringify(list),
body: formData,
})
if (!r.ok) {
@@ -146,45 +127,18 @@ export async function lists_create(list: List, avatar?: File) {
const model: List = await r.json();
const formData = new FormData();
if (avatar) {
formData.append("avatar", avatar);
}
r = await fetch(`/api/v1/list/${model.id!}/file`, {
method: 'POST',
body: formData,
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
return await r.json();
return model;
}
export async function lists_update(list: List, avatar?: File) {
let r = await fetch('/api/v1/list/' + list.id, {
method: 'POST',
body: JSON.stringify(list),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const model: List = await r.json();
const formData = new FormData();
const formData = objectToFormData(list)
if (avatar) {
formData.append("avatar", avatar);
}
r = await fetch(`/api/v1/list/${model.id!}/file`, {
let r = await fetch('/api/v1/list/form/' + list.id, {
method: 'POST',
body: formData,
})
@@ -193,6 +147,9 @@ export async function lists_update(list: List, avatar?: File) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const model: List = await r.json();
return model;
}
export async function lists_add_trail(list: List, trail: Trail) {
@@ -275,17 +232,17 @@ function buildSearchFilterText(user: AuthRecord, filter: ListFilter): string {
if (filter.public !== undefined) {
filterText += `(public = ${filter.public}`
if (!filter.author?.length || filter.author == user?.id) {
filterText += ` OR author = ${user?.id}`
if (!filter.author?.length || filter.author == user?.actor) {
filterText += ` OR author = ${user?.actor}`
}
filterText += ")"
}
if (filter.shared !== undefined) {
if (filter.shared === true) {
filterText += ` OR shares = ${user?.id}`
filterText += ` OR shares = ${user?.actor}`
} else {
filterText += ` AND NOT shares = ${user?.id}`
filterText += ` AND NOT shares = ${user?.actor}`
}
}
@@ -293,4 +250,44 @@ function buildSearchFilterText(user: AuthRecord, filter: ListFilter): string {
}
return filterText
}
export async function searchResultToLists(hits: Hits<ListSearchResult>): Promise<List[]> {
const lists: List[] = []
for (const h of hits) {
const l: List & RecordModel = {
collectionId: "lists",
collectionName: "lists",
updated: new Date(h.created * 1000).toISOString(),
author: h.author,
name: h.name,
public: h.public,
description: h.description,
id: h.id,
trails: Array(h.trails).fill("000000000000000"),
avatar: h.avatar,
elevation_gain: h.elevation_gain,
elevation_loss: h.elevation_loss,
distance: h.distance,
duration: h.duration,
iri: h.iri,
expand: {
author: {
icon: h.author_avatar,
id: h.author,
username: h.author_name,
} as any,
list_share_via_list: h.shares?.map(s => ({
permission: "view",
list: h.id,
actor: s,
})),
}
}
lists.push(l)
}
return lists
}

View File

@@ -8,6 +8,7 @@ export async function notifications_index(data: { recipient: string, seen?: bool
const r = await f('/api/v1/notification?' + new URLSearchParams({
filter: `created<=@month&&recipient='${data.recipient}'` + (data.seen !== undefined ? `&&seen=${data.seen}` : ''),
sort: '+seen,-created',
expand: 'author',
page: page.toString(),
"perPage": perPage.toString()
}), {

View File

@@ -0,0 +1,136 @@
import type { TimelineItem } from "$lib/models/timeline";
import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailSearchResult } from "$lib/models/trail";
import { APIError } from "$lib/util/api_util";
import type { Hits } from "meilisearch";
import type { ListResult } from "pocketbase";
import { searchResultToTrailList } from "./trail_store";
import type { SummitLog, SummitLogFilter } from "$lib/models/summit_log";
import { buildFilterText } from "./summit_log_store";
import type { ListFilter } from "$lib/models/list";
import type { ListSearchResult } from "./search_store";
import { searchResultToLists } from "./list_store";
let timeline: TimelineItem[] = []
export async function profile_show(handle: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f('/api/v1/profile/' + handle, {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const response = await r.json()
return response;
}
export async function profile_lists_index(handle: string, filter: ListFilter, page: number = 1, perPage: number = 6, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f(`/api/v1/profile/${handle}/lists`, {
method: "POST",
body: JSON.stringify({
q: filter.q,
options: {
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`],
hitsPerPage: perPage,
page: page
}
}),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const result: { page: number, totalPages: number, hits: Hits<ListSearchResult> } = await r.json();
if (result.hits.length == 0) {
return { items: [], ...result };
}
const resultLists = await searchResultToLists(result.hits)
return { items: resultLists, ...result };
}
export async function profile_timeline_index(handle: string, page: number, perPage: number = 1, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f(`/api/v1/profile/${handle}/timeline?` + new URLSearchParams({
page: page.toString(),
perPage: perPage.toString(),
sort: '-created'
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const fetchedTimeline: ListResult<TimelineItem> = await r.json()
const result = page > 1 ? [...timeline, ...fetchedTimeline.items] : fetchedTimeline.items
timeline = result;
return { ...fetchedTimeline, items: result };
}
export async function profile_trails_index(handle: string, filter: TrailFilter, page: number = 1, perPage: number = 1, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let r = await f(`/api/v1/profile/${handle}/trails`, {
method: "POST",
body: JSON.stringify({
q: filter.q,
options: {
attributesToRetrieve: defaultTrailSearchAttributes,
sort: [`${filter.sort}:${filter.sortOrder == "+" ? "asc" : "desc"}`],
hitsPerPage: 12,
page: page
}
}),
});
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const result: { page: number, totalPages: number, hits: Hits<TrailSearchResult> } = await r.json();
if (result.hits.length == 0) {
return { items: [], ...result };
}
const resultTrails: Trail[] = await searchResultToTrailList(result.hits)
return { items: resultTrails, ...result };
}
export async function profile_stats_index(handle: string, filter: SummitLogFilter, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const filterText = buildFilterText(filter);
const r = await f(`/api/v1/profile/${handle}/stats?` + new URLSearchParams({
filter: filterText,
perPage: "-1",
expand: "trail.category,author",
sort: "+date",
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const result: ListResult<SummitLog> = await r.json();
return result.items;
}

View File

@@ -1,7 +1,9 @@
import { env } from "$env/dynamic/public";
import { defaultTrailSearchAttributes } from "$lib/models/trail";
import type { Actor } from "$lib/models/activitypub/actor";
import { defaultTrailSearchAttributes, type TrailSearchResult } from "$lib/models/trail";
import { APIError } from "$lib/util/api_util";
import type { Hits, MultiSearchParams, MultiSearchResponse, MultiSearchResult, SearchParams, SearchResponse } from "meilisearch";
import type { ListResult } from "pocketbase";
export type LocationSearchResult = {
name: string;
@@ -12,37 +14,23 @@ export type LocationSearchResult = {
type: string;
}
export type TrailSearchResult = {
id: string;
_geo: {
lat: number,
lon: number
}
author: string;
category: string;
completed: boolean;
created: number;
date: number;
description: string;
difficulty: "easy" | "moderate" | "difficult"
distance: number;
duration: number
elevation_gain: number;
elevation_loss: number
location: string;
name: string;
public: boolean;
polyline?: string;
}
export type ListSearchResult = {
id: string;
author: string;
author_name: string;
author_avatar: string;
avatar?: string;
created: number;
description: string;
name: string;
elevation_gain: number;
elevation_loss: number;
distance: number,
duration: number,
public: boolean;
trails: string[]
trails: number
shares?: string[];
iri?: string;
}
type NominatimResponse = {
@@ -202,4 +190,21 @@ export async function searchMulti(options: MultiSearchParams): Promise<MultiSear
return response.results
}
export async function searchActors(q: string, includeSelf: boolean = true): Promise<Actor[]> {
try {
const r = await fetch(`/api/v1/search/actor?q=${q}&includeSelf=${includeSelf}`,)
if (!r.ok) {
return []
}
const response: ListResult<Actor> = await r.json()
return response.items
} catch (e) {
console.log(e);
return []
}
}

View File

@@ -3,20 +3,19 @@ import { APIError } from "$lib/util/api_util";
import { type AuthRecord, type ListResult } from "pocketbase";
import { get, writable, type Writable } from "svelte/store";
import { currentUser } from "./user_store";
import { isURL, objectToFormData } from "$lib/util/file_util";
export const summitLog: Writable<SummitLog> = writable(new SummitLog(new Date().toISOString().substring(0, 10)));
export const summitLogs: Writable<SummitLog[]> = writable([]);
export async function summit_logs_index(author: string, filter?: SummitLogFilter, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let filterText = `author='${author}'`
filterText += filter ? "&&" + buildFilterText(filter) : "";
export async function summit_logs_index(filter?: SummitLogFilter, handle?: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/summit-log?' + new URLSearchParams({
filter: filterText,
...(filter ? { filter: buildFilterText(filter) } : {}),
perPage: "-1",
expand: "trails_via_summit_logs.category",
expand: "trail.category,author",
sort: "+date",
...(handle ? { handle } : {})
}), {
method: 'GET',
})
@@ -28,18 +27,6 @@ export async function summit_logs_index(author: string, filter?: SummitLogFilter
const fetchedSummitLogs: ListResult<SummitLog> = await r.json();
// for (const log of fetchedSummitLogs.items) {
// if (!log.gpx) {
// continue
// }
// const gpxData: string = await fetchGPX(log as any, f);
// if (!log.expand) {
// log.expand = {};
// }
// log.expand.gpx_data = gpxData;
// }
summitLogs.set(fetchedSummitLogs.items);
return fetchedSummitLogs;
@@ -51,11 +38,27 @@ export async function summit_logs_create(summitLog: SummitLog, f: (url: RequestI
throw Error("Unauthenticated")
}
summitLog.author = user.id
summitLog.author = user.actor
let r = await f('/api/v1/summit-log', {
const formData = objectToFormData(summitLog, ["expand"])
if (summitLog._gpx && summitLog._gpx instanceof File) {
formData.append("gpx", summitLog._gpx)
}
if (summitLog._photos && summitLog._photos.length) {
for (const photo of summitLog._photos) {
formData.append("photos", photo)
}
}
let r = await f('/api/v1/summit-log/form?' + new URLSearchParams({
expand: "author"
}), {
method: 'PUT',
body: JSON.stringify(summitLog),
body: formData,
})
if (!r.ok) {
@@ -65,43 +68,6 @@ export async function summit_logs_create(summitLog: SummitLog, f: (url: RequestI
let model: SummitLog = await r.json();
if (summitLog._gpx && summitLog._gpx instanceof File) {
const formData = new FormData()
formData.append("gpx", summitLog._gpx)
r = await f(`/api/v1/summit-log/${model.id!}/file`, {
method: 'POST',
body: formData,
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
}
if (summitLog._photos && summitLog._photos.length) {
const formData = new FormData()
for (const photo of summitLog._photos) {
formData.append("photos", photo)
}
r = await fetch(`/api/v1/summit-log/${model.id!}/file`, {
method: 'POST',
body: formData,
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
}
return model;
}
@@ -111,19 +77,9 @@ export async function summit_logs_update(oldSummitLog: SummitLog, newSummitLog:
throw Error("Unauthenticated")
}
newSummitLog.author = user.id
newSummitLog.author = user.actor
let r = await fetch('/api/v1/summit-log/' + newSummitLog.id, {
method: 'POST',
body: JSON.stringify(newSummitLog),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const formData = new FormData()
const formData = objectToFormData(newSummitLog, ["expand"])
for (const photo of newSummitLog._photos ?? []) {
formData.append("photos", photo)
@@ -139,7 +95,9 @@ export async function summit_logs_update(oldSummitLog: SummitLog, newSummitLog:
formData.append("gpx", newSummitLog._gpx);
}
r = await fetch(`/api/v1/summit-log/${newSummitLog.id!}/file`, {
let r = await fetch('/api/v1/summit-log/form/' + newSummitLog.id + '?' + new URLSearchParams({
expand: "author"
}), {
method: 'POST',
body: formData,
})
@@ -165,11 +123,11 @@ export async function summit_logs_delete(summitLog: SummitLog) {
}
function buildFilterText(filter: SummitLogFilter,): string {
export function buildFilterText(filter: SummitLogFilter,): string {
let filterText: string = "";
if (filter.category.length > 0) {
filterText += `trails_via_summit_logs.category!=null&&'${filter.category.join(",")}'~trails_via_summit_logs.category`;
filterText += `trail.category!=null&&'${filter.category.join(",")}'~trail.category`;
}
if (filter.startDate) {
@@ -180,6 +138,17 @@ function buildFilterText(filter: SummitLogFilter,): string {
filterText += `${filter.category.length || filter.startDate ? '&&' : ''}date<='${filter.endDate}'`
}
if (filter.trail) {
if (filter.category.length || filter.startDate || filter.endDate) {
filterText += "&&"
}
if (isURL(filter.trail)) {
filterText += `trail='${filter.trail}'||trail.iri='${filter.trail}'||trail='${filter.trail.substring(filter.trail.length - 15)}'`;
} else {
filterText += `trail='${filter.trail}'`
}
}
return filterText;
}

View File

@@ -0,0 +1,56 @@
import type { TrailLike } from "$lib/models/trail_like";
import { APIError } from "$lib/util/api_util";
import { type ListResult } from "pocketbase";
export async function trail_like_index(data: { trail?: string, actor?: string }, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let filter = ""
if (data.trail) {
filter += `trail='${data.trail}'`
} else if (data.actor) {
filter += `actor='${data.actor}'`
}
const r = await f('/api/v1/trail-like?' + new URLSearchParams({
filter: filter,
expand: "actor"
}), {
method: 'GET',
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const response: ListResult<TrailLike> = await r.json();
return response.items;
}
export async function trail_like_create(like: TrailLike) {
let r = await fetch('/api/v1/trail-like', {
method: 'PUT',
body: JSON.stringify(like),
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const response: TrailLike = await r.json()
return response
}
export async function trail_like_delete(like: TrailLike) {
const r = await fetch('/api/v1/trail-like/delete', {
method: 'POST',
body: JSON.stringify(like)
})
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
}

View File

@@ -6,9 +6,19 @@ import { writable, type Writable } from "svelte/store";
export const shares: Writable<TrailShare[]> = writable([])
export async function trail_share_index(data: { trail?: string, user?: string }, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
export async function trail_share_index(data: { trail?: string, actor?: string, actorIRI?: string }, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
let filter = ""
if (data.trail) {
filter += `trail='${data.trail}'`
} else if (data.actor) {
filter += `actor='${data.actor}'`
} else if (data.actorIRI) {
filter += `actor.iri='${data.actorIRI}'`
}
const r = await f('/api/v1/trail-share?' + new URLSearchParams({
filter: data.trail ? `trail='${data.trail}'` : data.user ? `user='${data.user}'` : '',
filter: filter,
expand: "actor"
}), {
method: 'GET',
})

View File

@@ -4,7 +4,7 @@ import { defaultTrailSearchAttributes, Trail, type TrailFilter, type TrailFilter
import type { Waypoint } from "$lib/models/waypoint";
import { APIError } from "$lib/util/api_util";
import { deepEqual } from "$lib/util/deep_util";
import { getFileURL } from "$lib/util/file_util";
import { getFileURL, objectToFormData } from "$lib/util/file_util";
import * as M from "maplibre-gl";
import type { Hits } from "meilisearch";
import { type AuthRecord, type ListResult, type RecordModel } from "pocketbase";
@@ -22,7 +22,7 @@ export const editTrail: Writable<Trail> = writable(new Trail(""));
export async function trails_index(perPage: number = 21, random: boolean = false, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f('/api/v1/trail?' + new URLSearchParams({
"perPage": perPage.toString(),
expand: "category,waypoints,summit_logs,tags",
expand: "category,waypoints,summit_logs_via_trail,tags",
sort: random ? "@random" : "",
}), {
method: 'GET',
@@ -50,9 +50,9 @@ export async function trails_recommend(size: number, f: (url: RequestInfo | URL,
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const response: Trail[] = await r.json()
const response: Hits<TrailSearchResult> = await r.json()
return response;
return searchResultToTrailList(response);
}
@@ -132,9 +132,12 @@ export async function trails_search_bounding_box(northEast: M.LngLat, southWest:
}
export async function trails_show(id: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
export async function trails_show(id: string, handle?: string, loadGPX?: boolean, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch) {
const r = await f(`/api/v1/trail/${id}?` + new URLSearchParams({
expand: "category,waypoints,summit_logs,trail_share_via_trail,tags",
expand: "category,waypoints,summit_logs_via_trail,summit_logs_via_trail.author,trail_share_via_trail.actor,trail_like_via_trail,tags,author",
...(handle ? { handle } : {})
}), {
method: 'GET',
})
@@ -157,7 +160,7 @@ export async function trails_show(id: string, loadGPX?: boolean, f: (url: Reques
response.expand.gpx_data = gpxData;
for (const log of response.expand.summit_logs ?? []) {
for (const log of response.expand.summit_logs_via_trail ?? []) {
const gpxData: string = await fetchGPX(log, f);
if (!log.expand) {
@@ -188,10 +191,6 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
}, f, user);
trail.waypoints.push(model.id!);
}
for (const summitLog of trail.expand?.summit_logs ?? []) {
const model = await summit_logs_create(summitLog, f);
trail.summit_logs.push(model.id!);
}
for (const tag of trail.expand?.tags ?? []) {
if (!tag.id) {
const model = await tags_create(tag)
@@ -201,34 +200,22 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
}
}
trail.author = user.id
trail.author = user.actor
let r = await f(`/api/v1/trail?` + new URLSearchParams({
expand: "category,waypoints,summit_logs,trail_share_via_trail,tags",
}), {
method: 'PUT',
body: JSON.stringify({ ...trail }),
})
const formData = objectToFormData(trail)
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
let model: Trail = await r.json();
const formData = new FormData()
if (gpx) {
formData.append("gpx", gpx);
formData.set("gpx", gpx);
}
for (const photo of photos) {
formData.append("photos", photo)
formData.set("photos", photo)
}
r = await f(`/api/v1/trail/${model.id!}/file`, {
method: 'POST',
let r = await f(`/api/v1/trail/form?` + new URLSearchParams({
expand: "category,waypoints,summit_logs_via_trail,trail_share_via_trail,tags",
}), {
method: 'PUT',
body: formData,
})
@@ -237,15 +224,19 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl
throw new APIError(r.status, response.message, response.detail)
}
const modelWithFiles: Trail = await r.json();
let model: Trail = await r.json();
model.photos = modelWithFiles.photos;
for (const summitLog of trail.expand?.summit_logs_via_trail ?? []) {
summitLog.trail = model.id!;
await summit_logs_create(summitLog, f);
}
return model;
}
export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: File[], gpx?: File | Blob | null) {
newTrail.author = oldTrail.author
const waypointUpdates = compareObjectArrays<Waypoint>(oldTrail.expand?.waypoints ?? [], newTrail.expand?.waypoints ?? []);
@@ -269,15 +260,15 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
const success = await waypoints_delete(deletedWaypoint);
}
const summitLogUpdates = compareObjectArrays<SummitLog>(oldTrail.expand?.summit_logs ?? [], newTrail.expand?.summit_logs ?? []);
const summitLogUpdates = compareObjectArrays<SummitLog>(oldTrail.expand?.summit_logs_via_trail ?? [], newTrail.expand?.summit_logs_via_trail ?? []);
for (const summitLog of summitLogUpdates.added) {
summitLog.trail = newTrail.id!
const model = await summit_logs_create(summitLog);
newTrail.summit_logs.push(model.id!);
}
for (const updatedSummitLog of summitLogUpdates.updated) {
const oldSummitLog = oldTrail.expand?.summit_logs?.find(w => w.id == updatedSummitLog.id);
const oldSummitLog = oldTrail.expand?.summit_logs_via_trail?.find(w => w.id == updatedSummitLog.id);
const model = await summit_logs_update(oldSummitLog!, updatedSummitLog);
}
@@ -301,22 +292,8 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
newTrail.tags = newTrail.tags.filter(t => t != tag.id);
}
let r = await fetch(`/api/v1/trail/${newTrail.id}?` + new URLSearchParams({
expand: "category,waypoints,summit_logs,trail_share_via_trail,tags",
}), {
method: 'POST',
body: JSON.stringify({ ...newTrail, expand: undefined }),
})
const formData = objectToFormData(newTrail, ["expand"])
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
let model: Trail = await r.json();
const formData = new FormData()
if (gpx) {
formData.append("gpx", gpx);
}
@@ -327,14 +304,16 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
}
}
const deletedPhotos = oldTrail.photos.filter(oldPhoto => !newTrail.photos.find(newPhoto => newPhoto === oldPhoto));
for (const deletedPhoto of deletedPhotos) {
formData.append("photos-", deletedPhoto.replace(/^.*[\\/]/, ''));
}
r = await fetch(`/api/v1/trail/${newTrail.id!}/file`, {
let r = await fetch(`/api/v1/trail/form/${newTrail.id}?` + new URLSearchParams({
expand: "category,waypoints,summit_logs_via_trail,trail_share_via_trail,tags",
}), {
method: 'POST',
body: formData,
})
@@ -344,10 +323,14 @@ export async function trails_update(oldTrail: Trail, newTrail: Trail, photos?: F
throw new APIError(r.status, response.message, response.detail)
}
const response: Trail = await r.json();
model.photos = response.photos
model.gpx = response.gpx
let model: Trail = await r.json();
for (const log of model.expand?.summit_logs_via_trail ?? []) {
if (!log.expand) {
log.expand = {};
}
}
trail.set(model);
@@ -361,8 +344,8 @@ export async function trails_delete(trail: Trail) {
await waypoints_delete(waypoint);
}
}
if (trail.expand?.summit_logs) {
for (const summit_log of trail.expand.summit_logs) {
if (trail.expand?.summit_logs_via_trail) {
for (const summit_log of trail.expand.summit_logs_via_trail) {
await summit_logs_delete(summit_log);
}
}
@@ -455,14 +438,14 @@ export async function fetchGPX(trail: { gpx?: string } & Record<string, any>, f:
return gpxData
}
async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<Trail[]> {
export async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<Trail[]> {
const trails: Trail[] = []
for (const h of hits) {
const t: Trail & RecordModel = {
collectionId: "trails",
collectionName: "trails",
updated: new Date(h.created * 1000).toISOString(),
author: h.author,
author: h.author_name,
name: h.name,
photos: h.thumbnail ? [h.thumbnail] : [],
public: h.public,
@@ -484,26 +467,35 @@ async function searchResultToTrailList(hits: Hits<TrailSearchResult>): Promise<T
location: h.location,
gpx: h.gpx,
polyline: h.polyline,
domain: h.domain,
iri: h.iri,
thumbnail: 0,
like_count: h.like_count,
expand: {
author: {
collectionId: "users",
private: false,
collectionId: "activitypub_actors",
isLocal: (h.domain?.length ?? 0) == 0,
id: h.author,
avatar: h.author_avatar,
username: h.author_name
icon: h.author_avatar,
username: h.author_name,
domain: h.domain,
} as any,
trail_share_via_trail: h.shares?.map(s => ({
permission: "view",
trail: h.id,
user: s,
actor: s,
})),
trail_like_via_trail: h.likes?.map(l => ({
trail: h.id,
actor: l,
}))
}
}
trails.push(t)
}
return trails
}
@@ -569,17 +561,17 @@ function buildFilterText(user: AuthRecord, filter: TrailFilter, includeGeo: bool
if (filter.public !== undefined) {
filterText += `(public = ${filter.public}`
if (!filter.author?.length || filter.author == user?.id) {
filterText += ` OR author = ${user?.id}`
if (!filter.author?.length || filter.author == user?.actor) {
filterText += ` OR author = ${user?.actor}`
}
filterText += ")"
}
if (filter.shared !== undefined) {
if (filter.shared === true) {
filterText += ` OR shares = ${user?.id}`
filterText += ` OR shares = ${user?.actor}`
} else {
filterText += ` AND NOT shares = ${user?.id}`
filterText += ` AND NOT shares = ${user?.actor}`
}
}
@@ -587,6 +579,10 @@ function buildFilterText(user: AuthRecord, filter: TrailFilter, includeGeo: bool
}
*/
if (filter.liked === true) {
filterText += ` AND likes = ${user?.actor}`
}
if (filter.startDate) {
filterText += ` AND date >= ${new Date(filter.startDate).getTime() / 1000}`
}

View File

@@ -4,7 +4,7 @@ export type Upload = {
file: File;
status: "enqueued" | "uploading" | "cancelled" | "success" | "error" | "duplicate";
error?: string;
duplicate?: { id: string, name: string };
duplicate?: { id: string, domain: string, name: string };
progress: number;
function: (f: File, ignoreDuplicates?: boolean, onProgress?: (p: number) => void) => Promise<unknown>
};
@@ -42,7 +42,7 @@ export async function processUploadQueue(batchSize: number = 3, ignoreDuplicates
const u = batch[i];
if (r instanceof APIError && r.message == "Duplicate trail") {
u.status = "duplicate"
u.duplicate = { id: r.detail.id, name: r.detail.name };
u.duplicate = { id: r.detail.id, domain: r.detail.domain, name: r.detail.name };
} else if (r instanceof APIError) {
u.status = "error"
u.error = r.message

View File

@@ -45,14 +45,13 @@ export async function users_show(id: string, f: (url: RequestInfo | URL, config?
let r = await f(`/api/v1/user/anonymous/${id}`, {
method: 'GET',
})
const response: UserAnonymous = await r.json()
if (!r.ok) {
const response = await r.json();
throw new APIError(r.status, response.message, response.detail)
}
const response: UserAnonymous = await r.json()
return response;
}
export async function users_auth_methods(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<AuthMethodsList> {