feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology

A trip can be bound to a folder, tag or space in a self-hosted document store,
and documents then move in both directions: what someone uploads in TREK appears
there, and what someone files there appears in TREK.

Three decisions shape the rest.

The connection belongs to the TRIP, not to a person. Every other integration
here is per user, and photos are shared only through an opt-in flag; neither can
deliver "everyone on the trip sees the same documents", because visibility would
depend on whose credentials fetched a file, and a member without their own
Paperless account would see nothing. So the trip owner binds the trip once, the
server talks to the provider under that one identity, and TREK's own membership
decides who sees what. The provider never learns that TREK has members. When the
credential holder leaves the trip the binding goes to `orphaned` rather than
quietly carrying on with an ex-member's token.

TREK keeps the bytes. A synced document is an ordinary trip_files row, so
download, trash, offline cache, PDF export and backup keep working untouched,
and a provider outage does not blank out a trip that starts tomorrow.

Deletion never crosses the boundary on its own. A document that disappears
upstream is recorded as missing and waits for a human, and a run that sees more
than 40 percent of its known documents vanish at once refuses the whole listing
rather than acting on it: an unmounted share answers exactly like a mass delete.

The core reconciles by enumerating both sides, because the weakest provider sets
the model. A changed-since query cannot see a deletion, Papra does not move
updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and
Synology FileStation has no change feed at all. A webhook is therefore only ever
"look now", never a source of truth. Echo is caught by comparing content, not
timestamps: pushed_sha256 records what TREK itself wrote, so a provider
reporting TREK's own upload is not mistaken for a stranger's edit.

Provider differences are answered by capability data rather than by branching on
an id: stable ids where they exist (Paperless document id, oc:fileid, driveId)
and a normalised path where they do not, replace-in-place where the API offers
it, and a content hash from the listing only where the provider really supplies
one.

Admins switch providers on under Admin, Addons, Documents, mirroring how photo
providers hang off Journey, including the cascade and the 409 when the parent
addon is off. The per-trip setup lives in the file manager, where the person has
the context that makes the folder choice obvious.
This commit is contained in:
Maurice
2026-09-18 18:45:28 +02:00
parent 902dbbf84c
commit 2d7cb124b3
98 changed files with 20063 additions and 12 deletions
+30
View File
@@ -1358,6 +1358,36 @@ export const filesApi = {
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data),
}
/**
* Document sync: one provider connection per trip, shared by every member.
*
* Trip-scoped rather than user-scoped on purpose — a per-user connection would
* make a document's visibility depend on whose credentials fetched it, which is
* the opposite of what a shared trip needs.
*/
export const docsyncApi = {
providers: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/providers`).then(r => r.data),
status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data),
listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data),
saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data),
testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data).then(r => r.data),
deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data),
listScopes: (tripId: number | string, connectionId: number, q?: string) =>
apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {} }).then(r => r.data),
createScope: (tripId: number | string, connectionId: number, name: string) =>
apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { connectionId, name }).then(r => r.data),
listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data),
createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data).then(r => r.data),
updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data),
deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`).then(r => r.data),
syncNow: (tripId: number | string, linkId: number, full = false) =>
apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }).then(r => r.data),
items: (tripId: number | string, state?: string) =>
apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data),
resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') =>
apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }).then(r => r.data),
}
export const reservationsApi = {
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data),
+39 -2
View File
@@ -41,6 +41,10 @@ function SynologyIcon({ size = 14 }: { size?: number }) {
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
immich: ImmichIcon,
synologyphotos: SynologyIcon,
// Document providers reuse the Synology mark for the NAS and fall back to a
// generic lucide icon otherwise, rather than shipping four more brand SVGs
// for rows that are 14px tall.
synologydrive: SynologyIcon,
}
interface Addon {
@@ -137,7 +141,9 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
// Journey off disables every photo provider with it, and the response carries
// only Journey. Without re-reading, switching Journey back on brings the shelf
// up with providers the database has long since turned off.
if (addon.id === 'journey') await loadAddons()
// Same for Documents: switching it off disables every document provider in
// the database, and the response carries only the addon itself.
if (addon.id === 'journey' || addon.id === 'documents') await loadAddons()
toast.success(t('admin.addons.toast.updated'))
}
@@ -145,12 +151,17 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
return addon.type === 'photo_provider'
}
const isDocumentProviderAddon = (addon: Addon) => {
return addon.type === 'document_provider'
}
const isPhotosAddon = (addon: Addon) => {
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
}
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
const documentProviderAddons = addons.filter(isDocumentProviderAddon)
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
const globalAddons = addons.filter(a => a.type === 'global')
const integrationAddons = addons.filter(a => a.type === 'integration')
@@ -161,6 +172,13 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}))
const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({
key: provider.id,
label: provider.name,
description: provider.description,
enabled: provider.enabled,
toggle: () => handleToggle(provider),
}))
if (loading) {
return (
@@ -200,6 +218,25 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
)
})
}
// Document providers are the Documents tile's shelf, the way photo providers
// are Journey's. Unlike those they carry no credential form here: a document
// connection belongs to a trip, not to a user, so it is entered in the trip's
// file manager. The admin decides only whether a provider may be offered.
if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) {
return documentProviderOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
return (
<AddonSubRow
key={provider.key}
icon={ProviderIcon ? <ProviderIcon size={14} /> : undefined}
title={provider.label}
description={provider.description}
enabled={provider.enabled}
onToggle={provider.toggle}
/>
)
})
}
if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) {
return providerOptions.map(provider => {
const ProviderIcon = PROVIDER_ICONS[provider.key]
@@ -248,7 +285,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
{ key: 'global', addons: globalAddons },
{ key: 'integration', addons: integrationAddons },
].filter(g => g.addons.length > 0)
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.enabled).length
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
return (
+11 -1
View File
@@ -7,12 +7,13 @@ import { isMarkdown } from './FileManager.helpers'
import { FileManagerToolbar } from './FileManagerToolbar'
import { TrashView } from './FileManagerTrashView'
import { FilesView } from './FileManagerFilesView'
import DocSyncPanel from './docsync/DocSyncPanel'
export default function FileManager(props: FileManagerProps) {
const S = useFileManager(props)
const { lightboxIndex, setLightboxIndex, mediaFiles, assignFileId, previewFile, handlePaste, showTrash } = S
return (
<div className="flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
<div className="relative flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
{/* Lightbox */}
{lightboxIndex !== null && <ImageLightbox files={mediaFiles} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} />}
@@ -24,6 +25,15 @@ export default function FileManager(props: FileManagerProps) {
? <MarkdownPreviewModal {...S} />
: <PdfPreviewModal {...S} />)}
{/* Document sync, as an overlay rather than a route: it is configuration
for the documents on this screen, and sending someone to settings to
reach it loses the context that makes the folder choice obvious. */}
{S.showDocSync && (
<div className="absolute inset-0 z-20 overflow-y-auto bg-surface p-4">
<DocSyncPanel tripId={props.tripId} isOwner={S.isTripOwner} onClose={() => S.setShowDocSync(false)} />
</div>
)}
{/* Toolbar */}
<FileManagerToolbar {...S} />
@@ -1,8 +1,8 @@
import { Star, Trash2 } from 'lucide-react'
import { FolderSync, Star, Trash2 } from 'lucide-react'
import type { FileManagerState } from './useFileManager'
export function FileManagerToolbar(S: FileManagerState) {
const { showTrash, t, files, filterType, setFilterType, toggleTrash } = S
const { showTrash, t, files, filterType, setFilterType, toggleTrash, setShowDocSync } = S
return (
<div style={{ padding: '24px 28px 0', flexShrink: 0 }} className="max-md:!px-4 max-md:!pt-4">
<div style={{
@@ -63,12 +63,24 @@ export function FileManagerToolbar(S: FileManagerState) {
</>
)}
{/* Opens the sync panel. Placed next to the trash rather than in a menu
because the question it answers, "where do these documents live",
belongs on the same screen as the documents. */}
<button type="button" onClick={() => setShowDocSync(true)} title={t('docsync.title')} style={{
appearance: 'none', border: '1px solid var(--edge)', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 12px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'transparent', color: 'var(--content-muted)', flexShrink: 0, marginLeft: 'auto',
}}>
<FolderSync size={14} strokeWidth={2.5} /> <span className="hidden sm:inline">{t('docsync.title')}</span>
</button>
<button type="button" onClick={toggleTrash} style={{
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
background: 'var(--accent)', color: 'var(--accent-text)',
flexShrink: 0, marginLeft: 'auto',
flexShrink: 0,
opacity: showTrash ? 1 : 0.88,
transition: 'opacity 0.15s ease',
}}
@@ -0,0 +1,343 @@
import { useState } from 'react'
import { AlertTriangle, Check, Cloud, FolderSync, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import { useTranslation } from '../../../i18n/TranslationContext'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useDocSync, type DocSyncProvider, type DocSyncScope } from './useDocSync'
/**
* The trip's document sync, configured where the documents are.
*
* It lives in the file manager rather than in settings because this is the one
* screen where someone has the context "these documents belong in that folder".
* Only the trip owner can change it — the credential usually reaches the
* owner's whole archive — but every member can see where their documents go,
* which is the minimum a shared folder owes the people sharing it.
*/
export default function DocSyncPanel({ tripId, isOwner, onClose }: { tripId: number | string; isOwner: boolean; onClose: () => void }) {
const { t } = useTranslation()
const sync = useDocSync(tripId, true)
const [openProvider, setOpenProvider] = useState<string | null>(null)
if (sync.loading) {
return (
<div className="grid place-items-center py-10">
<Loader2 size={20} className="animate-spin text-content-faint" />
</div>
)
}
if (sync.providers.length === 0) {
return (
<div className="rounded-xl border border-edge bg-surface p-5 text-center">
<Cloud size={22} className="mx-auto text-content-faint" />
<p className="mt-2 text-body text-content">{t('docsync.noProviders')}</p>
<p className="mt-1 text-caption text-content-muted">{t('docsync.noProvidersHint')}</p>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-subtitle font-semibold text-content">{t('docsync.title')}</h3>
<p className="mt-0.5 text-caption text-content-muted">{t('docsync.subtitle')}</p>
</div>
<button type="button" onClick={onClose} className="rounded-lg p-1.5 text-content-muted hover:bg-surface-hover" aria-label={t('common.close')}>
<X size={16} />
</button>
</div>
{sync.links.map(link => {
const provider = sync.providers.find(p => p.id === link.providerId)
return (
<div key={link.id} className="rounded-xl border border-edge bg-surface p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<FolderSync size={16} className="text-accent" />
<span className="truncate text-body font-medium text-content">{provider?.name || link.providerId}</span>
<StateBadge state={link.lastSyncState} t={t} />
</div>
<p className="mt-1 truncate text-caption text-content-muted">
{link.remoteLabel || link.remoteRootPath || link.scopeKey}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={sync.busy === `sync-${link.id}`}
onClick={() => void sync.syncNow(link.id)}
className="flex items-center gap-1.5 rounded-lg border border-edge px-2.5 py-1.5 text-caption text-content hover:bg-surface-hover disabled:opacity-50"
>
{sync.busy === `sync-${link.id}`
? <Loader2 size={13} className="animate-spin" />
: <RefreshCw size={13} />}
{t('docsync.syncNow')}
</button>
{isOwner && (
<button
type="button"
onClick={() => void sync.removeLink(link.id)}
className="rounded-lg border border-edge p-1.5 text-content-muted hover:bg-surface-hover"
aria-label={t('docsync.unlink')}
>
<Trash2 size={13} />
</button>
)}
</div>
</div>
{link.lastSyncError && (
<p className="mt-2 flex items-center gap-1.5 text-caption text-danger">
<AlertTriangle size={13} />
{t(`docsync.error.${link.lastSyncError}`)}
</p>
)}
{isOwner && (
<div className="mt-3 grid gap-3 border-t border-edge pt-3 sm:grid-cols-2">
<label className="text-caption text-content-muted">
{t('docsync.direction')}
<select
value={link.direction}
onChange={e => void sync.updateLink(link.id, { direction: e.target.value })}
className="mt-1 w-full rounded-lg border border-edge bg-surface px-2 py-1.5 text-body text-content"
>
<option value="both">{t('docsync.directionBoth')}</option>
<option value="pull">{t('docsync.directionPull')}</option>
<option value="push">{t('docsync.directionPush')}</option>
</select>
</label>
<label className="text-caption text-content-muted">
{t('docsync.deletePolicy')}
<select
value={link.deletePolicy}
onChange={e => void sync.updateLink(link.id, { deletePolicy: e.target.value })}
className="mt-1 w-full rounded-lg border border-edge bg-surface px-2 py-1.5 text-body text-content"
>
<option value="unlink">{t('docsync.deleteUnlink')}</option>
<option value="trash">{t('docsync.deleteTrash')}</option>
</select>
</label>
<div className="flex items-center justify-between sm:col-span-2">
<span className="text-caption text-content-muted">{t('docsync.syncEnabled')}</span>
<ToggleSwitch
on={link.syncEnabled}
onToggle={() => void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })}
/>
</div>
{/* Shown only where TREK could not subscribe itself: Papra's
webhook API is closed to API keys and Nextcloud's needs admin
rights, so the user pastes this in by hand. Polling carries
the binding either way. */}
{link.webhookUrl && (
<div className="sm:col-span-2">
<p className="text-caption text-content-muted">{t('docsync.webhookHint')}</p>
<code className="mt-1 block overflow-x-auto rounded-lg bg-surface-subtle px-2 py-1.5 text-caption text-content">
{link.webhookUrl}
</code>
</div>
)}
</div>
)}
</div>
)
})}
{Object.keys(sync.itemCounts).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(sync.itemCounts).map(([state, n]) => (
<span key={state} className="rounded-full border border-edge px-2.5 py-1 text-caption text-content-muted">
{t(`docsync.state.${state}`)}: {n}
</span>
))}
</div>
)}
{isOwner && (
<div className="space-y-2">
<p className="text-caption font-medium text-content-muted">{t('docsync.addProvider')}</p>
{sync.providers.map(p => (
<ProviderRow
key={p.id}
provider={p}
tripId={tripId}
sync={sync}
open={openProvider === p.id}
onOpen={() => setOpenProvider(openProvider === p.id ? null : p.id)}
/>
))}
</div>
)}
</div>
)
}
function StateBadge({ state, t }: { state: string; t: (k: string) => string }) {
const tone =
state === 'ok' ? 'text-success'
: state === 'never' ? 'text-content-faint'
: state === 'partial' ? 'text-warning'
: 'text-danger'
return <span className={`text-caption ${tone}`}>{t(`docsync.linkState.${state}`)}</span>
}
/** One provider: credentials, a probe, then a folder to bind the trip to. */
function ProviderRow({
provider, tripId, sync, open, onOpen,
}: {
provider: DocSyncProvider
tripId: number | string
sync: ReturnType<typeof useDocSync>
open: boolean
onOpen: () => void
}) {
const { t } = useTranslation()
const existing = sync.connectionFor(provider.id)
const [values, setValues] = useState<Record<string, string>>({})
const [insecure, setInsecure] = useState(existing?.allowInsecureTls ?? false)
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
const [scopes, setScopes] = useState<DocSyncScope[] | null>(null)
const [newScopeName, setNewScopeName] = useState('')
const baseUrl = values.base_url ?? existing?.baseUrl ?? ''
const field = (key: string) => values[key] ?? existing?.settings?.[key] ?? ''
const runTest = async () => {
const res = await sync.testConnection(provider.id, baseUrl, values, insecure)
setVerdict(res)
}
const save = async () => {
const ok = await sync.saveConnection(provider.id, baseUrl, values, insecure)
if (ok) setVerdict({ connected: true })
}
const loadScopes = async () => {
const conn = sync.connectionFor(provider.id)
if (!conn) return
const res = await sync.loadScopes(conn.id)
setScopes(res.scopes)
}
const bind = async (scope: DocSyncScope) => {
const conn = sync.connectionFor(provider.id)
if (!conn) return
await sync.createLink({
connectionId: conn.id,
scopeKey: scope.scopeKey,
remoteRootId: scope.remoteRootId,
remoteRootPath: scope.remoteRootPath,
remoteLabel: scope.label,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
})
setScopes(null)
}
return (
<div className="rounded-xl border border-edge bg-surface">
<button type="button" onClick={onOpen} className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left">
<span className="flex items-center gap-2">
<Cloud size={15} className="text-content-muted" />
<span className="text-body text-content">{provider.name}</span>
{existing && <Check size={13} className="text-success" />}
</span>
<Plus size={15} className={`text-content-faint transition-transform ${open ? 'rotate-45' : ''}`} />
</button>
{open && (
<div className="space-y-3 border-t border-edge px-4 py-3">
{provider.fields.map(f => (
<label key={f.field_key} className="block text-caption text-content-muted">
{t(`docsync.${f.label}`)}{f.required && ' *'}
{f.input_type === 'checkbox' ? (
<div className="mt-1">
<ToggleSwitch on={insecure} onToggle={() => setInsecure(!insecure)} />
</div>
) : (
<input
type={f.input_type === 'password' ? 'password' : 'text'}
// A stored secret is never sent back, so the field stays empty
// and an empty field means "keep what is stored".
placeholder={f.secret && existing?.secrets?.[f.field_key] ? '••••••••' : f.placeholder || ''}
value={f.secret ? (values[f.field_key] ?? '') : field(f.field_key)}
onChange={e => setValues({ ...values, [f.field_key]: e.target.value })}
className="mt-1 w-full rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-body text-content"
/>
)}
{f.hint && <span className="mt-0.5 block text-caption text-content-faint">{t(`docsync.${f.hint}`)}</span>}
</label>
))}
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={() => void runTest()} disabled={sync.busy === 'test'}
className="rounded-lg border border-edge px-3 py-1.5 text-caption text-content hover:bg-surface-hover disabled:opacity-50">
{sync.busy === 'test' ? <Loader2 size={13} className="animate-spin" /> : t('docsync.test')}
</button>
<button type="button" onClick={() => void save()} disabled={sync.busy === 'save'}
className="rounded-lg bg-accent px-3 py-1.5 text-caption text-accent-contrast disabled:opacity-50">
{t('common.save')}
</button>
{existing && (
<button type="button" onClick={() => void loadScopes()}
className="rounded-lg border border-edge px-3 py-1.5 text-caption text-content hover:bg-surface-hover">
{t('docsync.chooseFolder')}
</button>
)}
{verdict && (
<span className={`text-caption ${verdict.connected ? 'text-success' : 'text-danger'}`}>
{verdict.connected
? t('docsync.connected') + (verdict.account ? ` (${verdict.account})` : '')
: t(`docsync.error.${verdict.error || 'unknown'}`)}
</span>
)}
</div>
{scopes && (
<div className="space-y-2 rounded-lg border border-edge bg-surface-subtle p-3">
<p className="text-caption text-content-muted">{t('docsync.chooseFolderHint')}</p>
<div className="max-h-48 space-y-1 overflow-y-auto">
{scopes.map(s => (
<button key={s.scopeKey} type="button" onClick={() => void bind(s)}
className="block w-full truncate rounded-lg px-2 py-1.5 text-left text-caption text-content hover:bg-surface-hover">
{s.label}
{s.remoteRootPath && <span className="ml-2 text-content-faint">{s.remoteRootPath}</span>}
</button>
))}
{scopes.length === 0 && <p className="text-caption text-content-faint">{t('docsync.noFolders')}</p>}
</div>
<div className="flex gap-2">
<input
value={newScopeName}
onChange={e => setNewScopeName(e.target.value)}
placeholder={t('docsync.newFolderPlaceholder')}
className="flex-1 rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption text-content"
/>
<button
type="button"
disabled={!newScopeName.trim() || sync.busy === 'scope'}
onClick={async () => {
const conn = sync.connectionFor(provider.id)
if (!conn) return
const created = await sync.createScope(conn.id, newScopeName.trim())
setNewScopeName('')
await bind(created)
}}
className="rounded-lg border border-edge px-3 py-1.5 text-caption text-content hover:bg-surface-hover disabled:opacity-50"
>
{t('docsync.createFolder')}
</button>
</div>
</div>
)}
{sync.error && <p className="text-caption text-danger">{t(`docsync.error.${sync.error}`)}</p>}
</div>
)}
</div>
)
}
@@ -0,0 +1,222 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { docsyncApi } from '../../../api/client'
/**
* All document-sync state and actions, in one hook.
*
* Deliberately a hook rather than logic inside the panel: the phone shell is a
* separate component tree (client/src/mobile/screens/trip/...), and duplicating
* this across both would land on SonarCloud's 3% duplication budget the way the
* photo provider section already does. `useDawarichConnection` states the same
* reason for the same problem.
*/
export interface DocSyncField {
field_key: string
label: string
input_type: string
placeholder: string | null
hint: string | null
required: boolean
secret: boolean
}
export interface DocSyncProvider {
id: string
name: string
description: string | null
icon: string
available: boolean
fields: DocSyncField[]
}
export interface DocSyncConnection {
id: number
providerId: string
baseUrl: string
settings: Record<string, string>
secrets: Record<string, string>
allowInsecureTls: boolean
lastProbeState: string
lastProbeError: string | null
}
export interface DocSyncScope {
scopeKey: string
label: string
remoteRootId: string | null
remoteRootPath: string | null
}
export interface DocSyncLink {
id: number
connectionId: number
providerId: string
scopeKey: string
remoteLabel: string
remoteRootPath: string | null
direction: 'both' | 'pull' | 'push'
deletePolicy: 'unlink' | 'trash'
conflictPolicy: string
syncEnabled: boolean
lastSyncAt: string | null
lastSyncState: string
lastSyncError: string | null
webhookUrl: string | null
}
export function useDocSync(tripId: number | string, enabled: boolean) {
const [providers, setProviders] = useState<DocSyncProvider[]>([])
const [connections, setConnections] = useState<DocSyncConnection[]>([])
const [links, setLinks] = useState<DocSyncLink[]>([])
const [itemCounts, setItemCounts] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
if (!enabled) return
setLoading(true)
try {
const [p, c, l, s] = await Promise.all([
docsyncApi.providers(tripId),
docsyncApi.listConnections(tripId),
docsyncApi.listLinks(tripId),
docsyncApi.status(tripId),
])
setProviders(p as DocSyncProvider[])
setConnections(c as DocSyncConnection[])
setLinks(l as DocSyncLink[])
setItemCounts(((s as { items?: Record<string, number> }).items) || {})
setError(null)
} catch {
// A failure here means the addon is off or the user lost access, both of
// which the panel renders as "nothing to configure" rather than an alarm.
setProviders([])
} finally {
setLoading(false)
}
}, [tripId, enabled])
useEffect(() => { void load() }, [load])
const connectionFor = useCallback(
(providerId: string) => connections.find(c => c.providerId === providerId) || null,
[connections],
)
const saveConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('save')
setError(null)
try {
await docsyncApi.saveConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls })
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
/**
* Probe without saving. Always resolves to a verdict object — the route
* answers 200 even for an unreachable instance, because a form showing a typo
* needs a field to render, not an exception.
*/
const testConnection = useCallback(async (providerId: string, baseUrl: string, credentials: Record<string, string>, allowInsecureTls: boolean) => {
setBusy('test')
try {
return await docsyncApi.testConnection(tripId, { providerId, baseUrl, credentials, allowInsecureTls }) as {
connected: boolean; account?: string; error?: string; detail?: string
}
} catch (e: unknown) {
return { connected: false, error: readError(e) }
} finally {
setBusy(null)
}
}, [tripId])
const loadScopes = useCallback(async (connectionId: number, q?: string) => {
const res = await docsyncApi.listScopes(tripId, connectionId, q) as { scopes: DocSyncScope[]; error?: string }
return res
}, [tripId])
const createScope = useCallback(async (connectionId: number, name: string) => {
setBusy('scope')
try {
return await docsyncApi.createScope(tripId, connectionId, name) as DocSyncScope
} finally {
setBusy(null)
}
}, [tripId])
const createLink = useCallback(async (payload: Record<string, unknown>) => {
setBusy('link')
setError(null)
try {
await docsyncApi.createLink(tripId, payload)
await load()
return true
} catch (e: unknown) {
setError(readError(e))
return false
} finally {
setBusy(null)
}
}, [tripId, load])
const updateLink = useCallback(async (linkId: number, patch: Record<string, unknown>) => {
setLinks(prev => prev.map(l => (l.id === linkId ? { ...l, ...patch } as DocSyncLink : l)))
try {
await docsyncApi.updateLink(tripId, linkId, patch)
} finally {
await load()
}
}, [tripId, load])
const removeLink = useCallback(async (linkId: number) => {
setBusy('link')
try {
await docsyncApi.deleteLink(tripId, linkId)
await load()
} finally {
setBusy(null)
}
}, [tripId, load])
const syncNow = useCallback(async (linkId: number, full = false) => {
setBusy(`sync-${linkId}`)
try {
const res = await docsyncApi.syncNow(tripId, linkId, full)
await load()
return res as { state: string; pulled: number; pushed: number; conflicts: number; missing: number }
} finally {
setBusy(null)
}
}, [tripId, load])
const resolveConflict = useCallback(async (itemId: number, keep: 'trek' | 'provider' | 'both') => {
await docsyncApi.resolve(tripId, itemId, keep)
await load()
}, [tripId, load])
/** Providers the admin switched on that also have a working adapter. */
const usableProviders = useMemo(() => providers.filter(p => p.available), [providers])
return {
providers: usableProviders,
connections, links, itemCounts,
loading, busy, error,
connectionFor, load,
saveConnection, testConnection,
loadScopes, createScope,
createLink, updateLink, removeLink, syncNow, resolveConflict,
}
}
function readError(e: unknown): string {
const res = (e as { response?: { data?: { error?: string; message?: string } } })?.response
return res?.data?.error || res?.data?.message || 'unknown'
}
@@ -6,6 +6,7 @@ import { filesApi } from '../../api/client'
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { getAuthUrl } from '../../api/authUrl'
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
import { openFile as openFileInTab } from '../../utils/fileDownload'
@@ -30,6 +31,7 @@ export interface FileManagerProps {
*/
export function useFileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
const [uploading, setUploading] = useState(false)
const [showDocSync, setShowDocSync] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [showTrash, setShowTrash] = useState(false)
@@ -38,6 +40,17 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
const toast = useToast()
const can = useCanDo()
const trip = useTripStore((s) => s.trip)
const currentUser = useAuthStore((s) => s.user)
/**
* Only the trip owner may change a sync binding: the credential it stores
* usually reaches that person's entire document archive, so letting any
* member repoint it would share a folder the owner never chose to share.
* Instance admins are included because they already override every other
* permission check in the client.
*/
const isTripOwner = !!currentUser && (
currentUser.role === 'admin' || Number(trip?.user_id) === Number(currentUser.id)
)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
@@ -210,6 +223,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId,
getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete,
handleAssign, mediaFiles, openFile,
showDocSync, setShowDocSync, isTripOwner,
}
}