fix(docsync): read the retry limit back out, debounce the webhook, and cover the UI

The attempt counter and the backoff were written on every failure and never
consulted when planning the next run: rows were found in a live database at 46
attempts against a limit of 6, so a document a provider refuses — one it reads
as corrupt, a type it will not take, a full quota — was re-uploaded on every
cron tick forever. planReconcile now skips a row that is shelved or still inside
its window, and a manual run clears the counters, which is what "waits for a
person" has to mean.

The webhook had the same shape of bug: WEBHOOK_NUDGE_DEBOUNCE_SECONDS existed,
with a comment explaining it, and nothing read it. Providers fire once per
document, so twenty files dropped into a watched folder were twenty runs — the
in-flight guard answered `busy` to nineteen of them, throwing away nineteen
announcements, and the one run that did start had begun before most of the
changes landed. A burst now folds into one trailing run, and the binding is
re-read when the timer fires so a sync switched off in between does not get a
last run out of a stale row.

Four defects the new suites turned up, all fixed: the sync button lost its
accessible name below sm, where its label is hidden; the stored address was
never shown in the connect dialog although the form kept submitting it; the
allow-self-signed switch only read the connection at mount, so a stored "on"
showed as off and the next save wrote that back; and a refused folder create
escaped as an unhandled rejection with nothing said to anybody.

Also here: the phone shell gets the same feature (one sheet, four views, the
same hook as the desktop), the marks come from the product's own icon files
rather than redrawn approximations, and the 22 non-English locales carry the
new keys.

ADMIN-SVC-069 asserted that `documents` has no MCP surface. It does now, so the
test takes its example from MCP_GATED_ADDON_IDS instead of naming one.
This commit is contained in:
Maurice
2026-09-18 22:44:45 +02:00
parent 2d7cb124b3
commit 1430056d24
62 changed files with 7263 additions and 724 deletions
+4
View File
@@ -81,6 +81,10 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
// who has the book open, and where their pointers are)
'journey:book:peers',
'journey:book:cursor',
// Document sync — components/Files/docsync/useDocSync re-reads the bindings
// and their counts. Content-free like the bag ping: what a run changed is
// per-binding server state, and the files it moved arrive as file:* events.
'docsync:changed',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
+6 -13
View File
@@ -9,6 +9,7 @@ import CustomSelect from '../shared/CustomSelect'
import EmptyState from '../shared/EmptyState'
import DawarichIcon from '../shared/DawarichIcon'
import AirTrailIcon from '../shared/AirTrailIcon'
import { DOCUMENT_PROVIDER_ICONS, SynologyDriveIcon } from '../shared/DocumentProviderIcons'
import AddonTile from './AddonTile'
import AddonSubRow from './AddonSubRow'
@@ -30,21 +31,13 @@ function ImmichIcon({ size = 14 }: { size?: number }) {
)
}
function SynologyIcon({ size = 14 }: { size?: number }) {
return (
<svg viewBox="0 0 24 24" width={size} height={size} style={{ flexShrink: 0 }}>
<path d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z" fill="currentColor" />
</svg>
)
}
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,
// Photos and Drive are the same brand, so they carry the same mark.
synologyphotos: SynologyDriveIcon,
// The document providers' own marks, single-colour so they read as glyphs in
// a row of lucide icons and invert with the theme.
...DOCUMENT_PROVIDER_ICONS,
}
interface Addon {
+11 -7
View File
@@ -13,7 +13,7 @@ export default function FileManager(props: FileManagerProps) {
const S = useFileManager(props)
const { lightboxIndex, setLightboxIndex, mediaFiles, assignFileId, previewFile, handlePaste, showTrash } = S
return (
<div className="relative flex flex-col h-full" style={{ fontFamily: "var(--font-system)" }} onPaste={handlePaste} tabIndex={-1}>
<div className="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)} />}
@@ -25,13 +25,17 @@ 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. */}
{/* Document sync opens as its own dialog: it is configuration for the
documents on this screen, so it belongs here rather than in settings,
but it is a task with a beginning and an end and does not belong
inside the list it configures. */}
{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>
<DocSyncPanel
tripId={props.tripId}
tripTitle={S.trip?.title}
isOwner={S.isTripOwner}
onClose={() => S.setShowDocSync(false)}
/>
)}
{/* Toolbar */}
@@ -0,0 +1,348 @@
// FE-DOCSYNC-BIND-001 to FE-DOCSYNC-BIND-025
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '../../../../tests/helpers/render'
import DocSyncBinding from './DocSyncBinding'
import type { DocSyncLink, DocSyncProvider, useDocSync } from './useDocSync'
type Sync = ReturnType<typeof useDocSync>
const syncNow = vi.fn(async (_linkId: number) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 }))
const updateLink = vi.fn(async (_linkId: number, _patch: Record<string, unknown>) => {})
const removeLink = vi.fn(async (_linkId: number) => {})
const WEBHOOK = 'https://trek.example/api/trips/3/docsync/hooks/abc123'
const provider: DocSyncProvider = {
id: 'paperless',
name: 'Paperless',
description: null,
icon: 'paperless',
available: true,
fields: [],
}
function makeLink(overrides: Partial<DocSyncLink> = {}): DocSyncLink {
return {
id: 4,
connectionId: 1,
providerId: 'paperless',
scopeKey: 'trek-trip-3',
remoteLabel: 'Lisbon papers',
remoteRootPath: '/TREK/Lisbon',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'ok',
lastSyncError: null,
webhookUrl: null,
holdings: { inTrek: 3, atProvider: 2, paired: 2, missing: 1 },
...overrides,
}
}
function makeSync(overrides: Partial<Sync> = {}): Sync {
return {
providers: [],
connections: [],
links: [],
itemCounts: {},
loading: false,
busy: null,
error: null,
connectionFor: () => null,
load: vi.fn(),
saveConnection: vi.fn(),
testConnection: vi.fn(),
loadScopes: vi.fn(),
createScope: vi.fn(),
createLink: vi.fn(),
updateLink,
removeLink,
syncNow,
resolveConflict: vi.fn(),
lastRunFor: () => ({ pulled: 0, pushed: 0 }),
...overrides,
} as unknown as Sync
}
function renderCard(
opts: {
link?: Partial<DocSyncLink>
sync?: Partial<Sync>
isOwner?: boolean
/** null means "the provider list has not arrived yet". */
provider?: DocSyncProvider | null
} = {},
) {
const utils = render(
<DocSyncBinding
link={makeLink(opts.link)}
provider={opts.provider === null ? undefined : (opts.provider ?? provider)}
sync={makeSync(opts.sync)}
isOwner={opts.isOwner ?? true}
/>,
)
const header = utils.container.querySelector('header') as HTMLElement
return { ...utils, header }
}
const openSettings = () => fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
const rowFor = (label: string) => screen.getByText(label).closest('div') as HTMLElement
beforeEach(() => {
vi.clearAllMocks()
})
describe('DocSyncBinding — running a sync', () => {
it('FE-DOCSYNC-BIND-001: "Sync now" asks the hook to run this binding', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }))
expect(syncNow).toHaveBeenCalledWith(4)
})
it('FE-DOCSYNC-BIND-002: while this binding runs the button reads "Syncing" and refuses another click', () => {
renderCard({ sync: { busy: 'sync-4' } })
const button = screen.getByRole('button', { name: 'Syncing' })
expect(button).toBeDisabled()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
fireEvent.click(button)
expect(syncNow).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-BIND-003: a run on another binding leaves this card ready', () => {
renderCard({ sync: { busy: 'sync-9' } })
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-BIND-004: the disconnect button unlinks this binding', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
expect(removeLink).toHaveBeenCalledWith(4)
})
})
describe('DocSyncBinding — what it says about itself', () => {
it('FE-DOCSYNC-BIND-005: a healthy binding shows its state as a dot, not as a word', () => {
renderCard()
expect(screen.queryByText('In sync')).not.toBeInTheDocument()
const dot = screen.getByTitle('In sync')
expect(dot).toBeInTheDocument()
expect(dot).toBeEmptyDOMElement()
})
it('FE-DOCSYNC-BIND-006: a failing binding spells the state out and appends the reason', () => {
renderCard({ link: { lastSyncState: 'failed', lastSyncError: 'unauthorized' } })
expect(screen.getByText('Failed')).toBeInTheDocument()
expect(screen.getByText('Failed · The credentials were refused.')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-007: a failing binding without a reason code shows the state alone', () => {
renderCard({ link: { lastSyncState: 'failed', lastSyncError: null } })
// once as the header badge, once as the banner underneath it
expect(screen.getAllByText('Failed')).toHaveLength(2)
})
it('FE-DOCSYNC-BIND-008: a partly synced binding also gets the banner', () => {
renderCard({ link: { lastSyncState: 'partial', lastSyncError: 'too_large' } })
expect(screen.getByText('Partly synced · The file is larger than the provider accepts.')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-009: a binding that never ran names its state but raises no alarm', () => {
renderCard({ link: { lastSyncState: 'never', lastSyncAt: null } })
expect(screen.getAllByText('Not synced yet')).toHaveLength(1)
expect(screen.getByText('not run yet')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-010: a paused binding is badged, an active one is not', () => {
const { unmount } = renderCard({ link: { syncEnabled: false } })
expect(screen.getByText('Paused')).toBeInTheDocument()
unmount()
renderCard({ link: { syncEnabled: true } })
expect(screen.queryByText('Paused')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-011: the last run is shown as elapsed time once there is one', () => {
renderCard({ link: { lastSyncAt: new Date(Date.now() - 2 * 3600_000).toISOString() } })
expect(screen.getByText(/hours ago/)).toBeInTheDocument()
expect(screen.queryByText('not run yet')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-012: the folder falls back to the path, then to the scope key', () => {
const { unmount } = renderCard({ link: { remoteLabel: '' } })
expect(screen.getByText('/TREK/Lisbon')).toBeInTheDocument()
unmount()
renderCard({ link: { remoteLabel: '', remoteRootPath: null } })
expect(screen.getByText('trek-trip-3')).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-013: without a provider record the card falls back to the provider id', () => {
const { header } = renderCard({ provider: null })
expect(within(header).getByText('paperless')).toBeInTheDocument()
expect(within(header).queryByText('Paperless')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-014: the flow bar gets the holdings, and zeros when there are none', () => {
const { unmount } = renderCard()
expect(screen.getByText('3')).toBeInTheDocument()
expect(screen.getByText('2')).toBeInTheDocument()
unmount()
renderCard({ link: { holdings: undefined } })
expect(screen.getAllByText('0')).toHaveLength(2)
})
})
describe('DocSyncBinding — the settings section', () => {
it('FE-DOCSYNC-BIND-015: the settings stay folded away until they are opened', () => {
renderCard()
const toggle = screen.getByRole('button', { name: 'Settings' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('Sync automatically')).toBeInTheDocument()
expect(screen.getByText('When a document is deleted')).toBeInTheDocument()
fireEvent.click(toggle)
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-016: a member gets no settings section and no disconnect button', () => {
renderCard({ isOwner: false })
expect(screen.queryByRole('button', { name: 'Settings' })).not.toBeInTheDocument()
expect(screen.queryByText('Sync automatically')).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument()
// a member may still trigger a run
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-BIND-017: turning "sync automatically" off patches the link', () => {
renderCard()
openSettings()
fireEvent.click(within(rowFor('Sync automatically')).getByRole('button'))
expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: false })
})
it('FE-DOCSYNC-BIND-018: turning it back on patches the other way', () => {
renderCard({ link: { syncEnabled: false } })
openSettings()
const toggle = within(rowFor('Sync automatically')).getByRole('button')
expect(toggle).toHaveAttribute('aria-pressed', 'false')
fireEvent.click(toggle)
expect(updateLink).toHaveBeenCalledWith(4, { syncEnabled: true })
})
it('FE-DOCSYNC-BIND-019: choosing another delete policy patches the link', () => {
renderCard()
openSettings()
const trigger = within(rowFor('When a document is deleted')).getByRole('button')
expect(trigger).toHaveTextContent('Keep both copies')
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: 'Move to recycle bin' }))
expect(updateLink).toHaveBeenCalledWith(4, { deletePolicy: 'trash' })
})
})
describe('DocSyncBinding — the webhook URL', () => {
const realClipboard = navigator.clipboard
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', { value: realClipboard, configurable: true, writable: true })
})
const stubClipboard = (writeText: (text: string) => Promise<void>) => {
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true, writable: true })
}
it('FE-DOCSYNC-BIND-020: a binding without a webhook shows no webhook section', () => {
renderCard({ link: { webhookUrl: null } })
openSettings()
expect(screen.queryByText('Instant updates')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Copy' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-021: a binding with a webhook shows the URL for pasting', () => {
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
expect(screen.getByText('Instant updates')).toBeInTheDocument()
expect(screen.getByText(WEBHOOK)).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-022: copying the webhook puts it on the clipboard and confirms', async () => {
const writeText = vi.fn(async (_text: string) => {})
stubClipboard(writeText)
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith(WEBHOOK))
expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument()
})
it('FE-DOCSYNC-BIND-023: a blocked clipboard leaves the URL on screen and the button unchanged', async () => {
const writeText = vi.fn(async (_text: string) => { throw new Error('not allowed') })
stubClipboard(writeText)
renderCard({ link: { webhookUrl: WEBHOOK } })
openSettings()
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await waitFor(() => expect(writeText).toHaveBeenCalled())
expect(screen.queryByRole('button', { name: 'Copied' })).not.toBeInTheDocument()
expect(screen.getByText(WEBHOOK)).toBeInTheDocument()
})
})
describe('DocSyncBinding — the direction lanes', () => {
it('FE-DOCSYNC-BIND-024: switching a lane off patches the direction', () => {
renderCard()
fireEvent.click(screen.getByRole('button', { name: 'Out to the store' }))
expect(updateLink).toHaveBeenCalledWith(4, { direction: 'pull' })
})
it('FE-DOCSYNC-BIND-025: a member cannot change the direction', () => {
renderCard({ isOwner: false })
const lane = screen.getByRole('button', { name: 'Out to the store' })
expect(lane).toBeDisabled()
fireEvent.click(lane)
expect(updateLink).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,214 @@
import { useState } from 'react'
import {
AlertTriangle, Check, ChevronDown, Clock, Copy, FolderOpen, Link2Off, RefreshCw,
} from 'lucide-react'
import CustomSelect from '../../shared/CustomSelect'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import Tooltip from '../../shared/Tooltip'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import DocSyncFlow, { type SyncDirection } from './DocSyncFlow'
import { Badge, LastRun, StateBadge } from './DocSyncBits'
import type { DocSyncLink, DocSyncProvider, useDocSync } from './useDocSync'
/**
* One binding: where this trip's documents live, which way they move, and what
* happened last time.
*
* The flow bar carries the direction because that is the one thing this feature
* is about. Everything a person sets once and forgets — the delete rule, the
* webhook URL — is folded away behind "Settings" so the card stays a status
* card rather than a form.
*/
export default function DocSyncBinding({
link,
provider,
sync,
isOwner,
}: {
link: DocSyncLink
provider?: DocSyncProvider
sync: ReturnType<typeof useDocSync>
isOwner: boolean
}) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [copied, setCopied] = useState(false)
const Icon = DOCUMENT_PROVIDER_ICONS[link.providerId]
const busy = sync.busy === `sync-${link.id}`
const needsAttention = link.lastSyncState !== 'ok' && link.lastSyncState !== 'never'
const copyWebhook = async () => {
if (!link.webhookUrl) return
try {
await navigator.clipboard.writeText(link.webhookUrl)
setCopied(true)
setTimeout(() => setCopied(false), 1600)
} catch {
// Clipboard is blocked outside a secure context; the URL is selectable.
}
}
return (
<article className="overflow-hidden rounded-2xl border border-edge bg-surface shadow-card">
<header className="flex flex-wrap items-start gap-x-3 gap-y-3 px-4 py-3.5">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl border border-edge bg-surface-secondary">
{Icon ? <Icon className="h-5 w-5 text-content" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="truncate text-subtitle font-semibold text-content">
{provider?.name || link.providerId}
</span>
{/* A healthy binding needs no word for it — the dot is the whole
message, and the states that do need words get them below. */}
<StateBadge state={link.lastSyncState} compact={link.lastSyncState === 'ok'} />
{!link.syncEnabled && <Badge tone="neutral">{t('docsync.binding.autoOff')}</Badge>}
</span>
{/* Where and when, as two facts rather than a sentence — they are what
a member checks before asking why a file has not turned up. */}
<span className="mt-1.5 flex flex-wrap items-center gap-1.5">
<Badge tone="neutral" icon={<FolderOpen size={12} />} title={link.remoteRootPath || undefined}>
<span className="max-w-[12rem] truncate">
{link.remoteLabel || link.remoteRootPath || link.scopeKey}
</span>
</Badge>
<Badge tone="neutral" icon={<Clock size={12} />}>
<LastRun at={link.lastSyncAt} />
</Badge>
</span>
</span>
{/* Both controls are h-9 so the square one cannot sit a pixel off the
other — the two together read as one segmented control. */}
<span className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => void sync.syncNow(link.id)}
disabled={busy}
className="flex h-9 items-center gap-2 rounded-lg border border-edge bg-surface px-3 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-60"
>
<RefreshCw size={14} className={busy ? 'animate-spin' : ''} />
{/* Taken out of the layout below sm rather than out of the document:
`hidden` left a screen reader announcing a bare "button" there,
and an aria-label would have frozen the name while the visible
text still changes to "Syncing". */}
<span className="sr-only sm:not-sr-only">{busy ? t('docsync.syncing') : t('docsync.syncNow')}</span>
</button>
{isOwner && (
<Tooltip label={t('docsync.unlink')}>
<button
type="button"
onClick={() => void sync.removeLink(link.id)}
className="grid h-9 w-9 place-items-center rounded-lg border border-edge bg-surface text-content-muted transition-colors hover:bg-danger-soft hover:text-danger"
aria-label={t('docsync.unlink')}
>
<Link2Off size={14} />
</button>
</Tooltip>
)}
</span>
</header>
{needsAttention && (
<p className="mx-4 mb-3 flex items-start gap-2 rounded-lg bg-warning-soft px-3 py-2 text-caption text-warning">
<AlertTriangle size={13} className="mt-px shrink-0" />
<span>
{t(`docsync.linkState.${link.lastSyncState}`)}
{link.lastSyncError ? ` · ${t(`docsync.error.${link.lastSyncError}`)}` : ''}
</span>
</p>
)}
<div className="px-4 pb-3">
<DocSyncFlow
direction={link.direction}
providerId={link.providerId}
providerName={provider?.name || link.providerId}
running={busy}
holdings={link.holdings ?? { inTrek: 0, atProvider: 0, paired: 0, missing: 0 }}
disabled={!isOwner}
onChange={(next: SyncDirection) => void sync.updateLink(link.id, { direction: next })}
/>
</div>
{isOwner && (
<>
<button
type="button"
onClick={() => setOpen(!open)}
aria-expanded={open}
className="flex w-full items-center gap-1.5 border-t border-edge-faint px-4 py-2.5 text-left text-caption text-content-muted transition-colors hover:bg-surface-hover"
>
<ChevronDown size={13} className={`transition-transform ${open ? 'rotate-180' : ''}`} />
{t('docsync.binding.settings')}
</button>
{open && (
<div className="space-y-3 border-t border-edge-faint bg-surface-secondary px-4 py-3">
<Row label={t('docsync.deletePolicy')} hint={t('docsync.binding.deleteHint')}>
<CustomSelect
size="sm"
value={link.deletePolicy}
onChange={v => void sync.updateLink(link.id, { deletePolicy: String(v) })}
options={[
{ value: 'unlink', label: t('docsync.deleteUnlink') },
{ value: 'trash', label: t('docsync.deleteTrash') },
]}
/>
</Row>
<Row label={t('docsync.syncEnabled')} hint={t('docsync.binding.autoHint')}>
<span className="flex justify-end">
<ToggleSwitch
on={link.syncEnabled}
onToggle={() => void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })}
/>
</span>
</Row>
{/* Only where TREK could not subscribe itself. Papra's webhook CRUD
is closed to API keys and Nextcloud's needs admin rights, so the
person pastes this in by hand; polling carries it either way. */}
{link.webhookUrl && (
<div>
{/* Same tier as the two rows above it — this is the third
setting, not a footnote to the second. */}
<span className="block text-body text-content">{t('docsync.binding.webhookTitle')}</span>
<p className="mt-1 text-caption text-content-muted">{t('docsync.webhookHint')}</p>
<div className="mt-1.5 flex items-stretch gap-1.5">
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-lg border border-edge bg-surface px-2.5 py-1.5 text-caption text-content-secondary">
{link.webhookUrl}
</code>
<button
type="button"
onClick={() => void copyWebhook()}
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-edge px-2.5 text-caption text-content-secondary transition-colors hover:bg-surface-hover"
>
{copied ? <Check size={13} className="text-success" /> : <Copy size={13} />}
{copied ? t('docsync.binding.copied') : t('docsync.binding.copy')}
</button>
</div>
</div>
)}
</div>
)}
</>
)}
</article>
)
}
function Row({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<span className="min-w-0">
<span className="block text-body text-content">{label}</span>
{hint && <span className="mt-0.5 block text-caption text-content-muted">{hint}</span>}
</span>
<span className="w-56 shrink-0">{children}</span>
</div>
)
}
@@ -0,0 +1,85 @@
import type { ReactNode } from 'react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { relativeTime } from '../../../utils/relativeTime'
/**
* The small shared pieces of the document-sync dialog.
*
* They live together because the panel, the store list and the binding card all
* label the same five sync states, and three hand-written copies of that mapping
* is how one of them ends up saying something different from the other two.
*/
export type Tone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger'
const TONES: Record<Tone, string> = {
neutral: 'bg-surface-secondary text-content-secondary',
accent: 'bg-accent-subtle text-accent-on',
success: 'bg-success-soft text-success',
warning: 'bg-warning-soft text-warning',
danger: 'bg-danger-soft text-danger',
}
/** A count or a short label, on a tinted pill. */
export function Badge({
tone = 'neutral',
icon,
children,
title,
}: {
tone?: Tone
icon?: ReactNode
children: ReactNode
title?: string
}) {
return (
<span
title={title}
className={`inline-flex items-center gap-1.5 whitespace-nowrap rounded-full px-2 py-0.5 text-caption font-medium ${TONES[tone]}`}
>
{icon}
{children}
</span>
)
}
/** The tone each sync state carries, in one place. */
function toneForState(state: string): Tone {
if (state === 'ok') return 'success'
if (state === 'partial') return 'warning'
if (state === 'never') return 'neutral'
return 'danger'
}
/**
* A binding's state as a dot plus its word.
*
* A dot alone is a colour puzzle and a word alone is easy to skim past; the two
* together survive both colour blindness and a glance.
*/
export function StateBadge({ state, compact }: { state: string; compact?: boolean }) {
const { t } = useTranslation()
const tone = toneForState(state)
const dot =
tone === 'success' ? 'bg-success'
: tone === 'warning' ? 'bg-warning'
: tone === 'danger' ? 'bg-danger'
: 'bg-content-faint'
if (compact) return <span className={`h-2 w-2 shrink-0 rounded-full ${dot}`} title={t(`docsync.linkState.${state}`)} />
return (
<Badge tone={tone} icon={<span className={`h-1.5 w-1.5 rounded-full ${dot}`} />}>
{t(`docsync.linkState.${state}`)}
</Badge>
)
}
/** "2h ago", or "never" when a binding has not run yet. */
export function LastRun({ at }: { at: string | null }) {
const { t, language, locale } = useTranslation()
if (!at) return <span className="text-content-faint">{t('docsync.binding.neverRun')}</span>
const ms = Date.parse(at)
if (Number.isNaN(ms)) return null
return <span title={new Date(ms).toLocaleString(locale)}>{relativeTime(ms, language)}</span>
}
@@ -0,0 +1,494 @@
// FE-DOCSYNC-CONNECT-001 to FE-DOCSYNC-CONNECT-027
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor, act, within } from '../../../../tests/helpers/render'
import type { DocSyncConnection, DocSyncProvider } from './useDocSync'
/** What the app really shows: `en` is bundled, so `t()` resolves for real here. */
const tx = (key: string): string => (en as unknown as Record<string, string>)[key] ?? key
const providers = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listConnections = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listLinks = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const status = vi.fn(async (_tripId: number | string): Promise<unknown> => ({ items: {}, links: [] }))
const saveConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ id: 1 }))
const testConnection = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: (tripId: number | string) => providers(tripId),
status: (tripId: number | string) => status(tripId),
listConnections: (tripId: number | string) => listConnections(tripId),
listLinks: (tripId: number | string) => listLinks(tripId),
saveConnection: (tripId: number | string, data: unknown) => saveConnection(tripId, data),
testConnection: (tripId: number | string, data: unknown) => testConnection(tripId, data),
},
}
})
import DocSyncConnectModal from './DocSyncConnectModal'
import { useDocSync } from './useDocSync'
const TRIP_ID = 42
const nextcloud: DocSyncProvider = {
id: 'nextcloud',
name: 'Nextcloud',
description: 'Two-way document sync with a Nextcloud folder over WebDAV',
icon: 'Cloud',
available: true,
fields: [
{ field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://cloud.example.com', hint: null, required: true, secret: false },
{ field_key: 'login_name', label: 'providerUsername', input_type: 'text', placeholder: 'username', hint: 'hintNextcloudLogin', required: true, secret: false },
{ field_key: 'app_password', label: 'providerAppPassword', input_type: 'password', placeholder: 'app password', hint: 'hintNextcloudAppPassword', required: true, secret: true },
{ field_key: 'base_path', label: 'providerBasePath', input_type: 'text', placeholder: '/TREK', hint: 'hintBasePath', required: false, secret: false },
{ field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: false, secret: false },
],
}
/** Synology is the one provider with a secret that is NOT a password box (the OTP). */
const synology: DocSyncProvider = {
id: 'synologydrive',
name: 'Synology Drive',
description: 'Two-way document sync with a folder on a Synology NAS',
icon: 'HardDrive',
available: true,
fields: [
{ field_key: 'base_url', label: 'providerUrl', input_type: 'url', placeholder: 'https://nas.example.com:5001', hint: 'hintSynologyUrl', required: true, secret: false },
{ field_key: 'password', label: 'providerPassword', input_type: 'password', placeholder: 'password', hint: null, required: true, secret: true },
{ field_key: 'otp_code', label: 'providerOTP', input_type: 'text', placeholder: '123456', hint: 'hintSynologyOtp', required: false, secret: true },
{ field_key: 'allow_insecure_tls', label: 'allowInsecureTls', input_type: 'checkbox', placeholder: null, hint: null, required: false, secret: false },
],
}
const connection = (overrides: Partial<DocSyncConnection> = {}): DocSyncConnection => ({
id: 9,
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
settings: { login_name: 'maurice', base_path: '/Archive/TREK' },
secrets: { app_password: 'stored' },
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
...overrides,
})
function Harness({
provider,
onClose,
onConnected,
waitForConnection,
}: {
provider: DocSyncProvider
onClose: () => void
onConnected: (providerId: string) => void
/**
* Hold the dialog back until the stored connection is in hand, the way the
* panel does: it only offers "connect" once it knows what is already there.
*/
waitForConnection?: boolean
}) {
const sync = useDocSync(TRIP_ID, true)
if (waitForConnection && !sync.connectionFor(provider.id)) return null
return <DocSyncConnectModal provider={provider} sync={sync} onClose={onClose} onConnected={onConnected} />
}
/** Let the hook's initial load land before anything is asserted. */
async function flush() {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
}
async function open(provider: DocSyncProvider = nextcloud, waitForConnection = false) {
const onClose = vi.fn()
const onConnected = vi.fn()
const utils = render(
<Harness provider={provider} onClose={onClose} onConnected={onConnected} waitForConnection={waitForConnection} />,
)
await flush()
return { ...utils, onClose, onConnected }
}
const testButton = () => screen.getByRole('button', { name: tx('docsync.test') })
const connectButton = () => screen.getByRole('button', { name: tx('docsync.connect.submit') })
function fillNextcloud() {
fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: 'https://cloud.example.com' } })
fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'maurice' } })
fireEvent.change(screen.getByPlaceholderText('app password'), { target: { value: 'app-pw' } })
}
beforeEach(() => {
vi.clearAllMocks()
providers.mockResolvedValue([])
listConnections.mockResolvedValue([])
listLinks.mockResolvedValue([])
status.mockResolvedValue({ items: {}, links: [] })
saveConnection.mockResolvedValue({ id: 1 })
testConnection.mockResolvedValue({ connected: true })
})
describe('DocSyncConnectModal form', () => {
it('FE-DOCSYNC-CONNECT-001: renders one labelled box per provider field and leaves the TLS field to the switch', async () => {
const { baseElement } = await open()
expect(screen.getByText(tx('docsync.providerUrl'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.providerUsername'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.providerAppPassword'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.providerBasePath'))).toBeInTheDocument()
// allow_insecure_tls is a switch, not a fifth text box
expect(baseElement.querySelectorAll('input')).toHaveLength(4)
expect(screen.getByText(tx('docsync.allowInsecureTls'))).toBeInTheDocument()
expect(screen.getByRole('button', { pressed: false })).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-002: a password field is masked and the other boxes are not', async () => {
await open()
expect(screen.getByPlaceholderText('app password')).toHaveAttribute('type', 'password')
expect(screen.getByPlaceholderText('https://cloud.example.com')).toHaveAttribute('type', 'text')
expect(screen.getByPlaceholderText('username')).toHaveAttribute('type', 'text')
expect(screen.getByPlaceholderText('/TREK')).toHaveAttribute('type', 'text')
})
it('FE-DOCSYNC-CONNECT-003: a secret the provider declares as plain text stays readable', async () => {
await open(synology)
expect(screen.getByPlaceholderText('password')).toHaveAttribute('type', 'password')
expect(screen.getByPlaceholderText('123456')).toHaveAttribute('type', 'text')
})
it('FE-DOCSYNC-CONNECT-004: only the optional fields carry the optional mark', async () => {
await open()
const optional = tx('docsync.connect.optional')
const basePath = screen.getByPlaceholderText('/TREK').closest('label') as HTMLElement
expect(within(basePath).getByText(optional)).toBeInTheDocument()
for (const placeholder of ['https://cloud.example.com', 'username', 'app password']) {
const field = screen.getByPlaceholderText(placeholder).closest('label') as HTMLElement
expect(within(field).queryByText(optional)).not.toBeInTheDocument()
}
expect(screen.getAllByText(optional)).toHaveLength(1)
})
it('FE-DOCSYNC-CONNECT-005: a field hint is translated, and a field without one gets no line', async () => {
await open()
const username = screen.getByPlaceholderText('username').closest('label') as HTMLElement
expect(within(username).getByText(tx('docsync.hintNextcloudLogin'))).toBeInTheDocument()
const baseUrl = screen.getByPlaceholderText('https://cloud.example.com').closest('label') as HTMLElement
expect(baseUrl.textContent?.trim()).toBe(tx('docsync.providerUrl'))
})
it('FE-DOCSYNC-CONNECT-006: the dialog is headed by the provider and explains what it will do', async () => {
await open()
expect(screen.getByRole('heading', { name: nextcloud.name })).toBeInTheDocument()
expect(screen.getByText(tx('docsync.connect.about.nextcloud'))).toBeInTheDocument()
expect(screen.getByText(tx('docsync.connect.insecureHint'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-007: escape closes the dialog without saving', async () => {
const { onClose, onConnected } = await open()
fireEvent.keyDown(document, { key: 'Escape' })
expect(onClose).toHaveBeenCalled()
expect(onConnected).not.toHaveBeenCalled()
expect(saveConnection).not.toHaveBeenCalled()
})
})
describe('DocSyncConnectModal required fields', () => {
it('FE-DOCSYNC-CONNECT-008: nothing can be saved or probed while a required field is empty', async () => {
await open()
expect(connectButton()).toBeDisabled()
expect(testButton()).toBeDisabled()
fireEvent.click(connectButton())
fireEvent.click(testButton())
expect(saveConnection).not.toHaveBeenCalled()
expect(testConnection).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-CONNECT-009: a missing password alone is enough to refuse the save', async () => {
await open()
fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: 'https://cloud.example.com' } })
fireEvent.change(screen.getByPlaceholderText('username'), { target: { value: 'maurice' } })
expect(connectButton()).toBeDisabled()
fireEvent.change(screen.getByPlaceholderText('app password'), { target: { value: 'app-pw' } })
expect(connectButton()).toBeEnabled()
})
it('FE-DOCSYNC-CONNECT-010: an address of nothing but spaces does not count as filled', async () => {
await open()
fillNextcloud()
expect(connectButton()).toBeEnabled()
fireEvent.change(screen.getByPlaceholderText('https://cloud.example.com'), { target: { value: ' ' } })
expect(connectButton()).toBeDisabled()
expect(testButton()).toBeDisabled()
})
it('FE-DOCSYNC-CONNECT-011: an empty optional field does not hold the save back', async () => {
const { onConnected } = await open()
fillNextcloud()
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalled())
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: { base_url: 'https://cloud.example.com', login_name: 'maurice', app_password: 'app-pw' },
allowInsecureTls: false,
})
})
})
describe('DocSyncConnectModal probe verdict', () => {
it('FE-DOCSYNC-CONNECT-012: the probe carries the typed address, credentials and TLS choice', async () => {
await open()
fillNextcloud()
fireEvent.change(screen.getByPlaceholderText('/TREK'), { target: { value: '/Archive' } })
fireEvent.click(testButton())
await waitFor(() => expect(testConnection).toHaveBeenCalledWith(TRIP_ID, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: {
base_url: 'https://cloud.example.com',
login_name: 'maurice',
app_password: 'app-pw',
base_path: '/Archive',
},
allowInsecureTls: false,
}))
expect(saveConnection).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-CONNECT-013: a reachable provider names the account it signed in as', async () => {
testConnection.mockResolvedValueOnce({ connected: true, account: 'maurice' })
await open()
fillNextcloud()
fireEvent.click(testButton())
const line = tx('docsync.connect.okAs').replace('{account}', 'maurice')
expect(await screen.findByText(line)).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-014: a reachable provider without an account just reports the connection', async () => {
testConnection.mockResolvedValueOnce({ connected: true })
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(await screen.findByText(tx('docsync.connected'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-015: a refused credential shows the translated reason', async () => {
testConnection.mockResolvedValueOnce({ connected: false, error: 'unauthorized' })
await open()
fillNextcloud()
fireEvent.click(testButton())
const line = await screen.findByText(tx('docsync.error.unauthorized'))
expect(line).toBeInTheDocument()
expect(screen.queryByText(tx('docsync.connected'))).not.toBeInTheDocument()
expect(line.parentElement?.querySelector('svg')?.getAttribute('class')).not.toMatch(/shield/)
})
it('FE-DOCSYNC-CONNECT-016: an address TREK itself blocked is marked with the shield, not the warning', async () => {
testConnection.mockResolvedValueOnce({ connected: false, error: 'ssrf_blocked' })
await open()
fillNextcloud()
fireEvent.click(testButton())
const line = await screen.findByText(tx('docsync.error.ssrf_blocked'))
expect(line.parentElement?.querySelector('svg')?.getAttribute('class')).toMatch(/shield/)
})
it('FE-DOCSYNC-CONNECT-017: a failure with no reason falls back to the unknown message', async () => {
testConnection.mockResolvedValueOnce({ connected: false })
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(await screen.findByText(tx('docsync.error.unknown'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-018: a request that throws still lands as a verdict rather than an empty footer', async () => {
testConnection.mockRejectedValueOnce({ response: { data: { error: 'timeout' } } })
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(await screen.findByText(tx('docsync.error.timeout'))).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-019: while the probe runs the dialog says so and the probe button is locked', async () => {
let settle: (value: unknown) => void = () => {}
testConnection.mockImplementationOnce(() => new Promise(resolve => { settle = resolve }))
await open()
fillNextcloud()
fireEvent.click(testButton())
expect(screen.getByText(tx('docsync.connect.testing'))).toBeInTheDocument()
expect(testButton()).toBeDisabled()
await act(async () => {
settle({ connected: true })
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(screen.queryByText(tx('docsync.connect.testing'))).not.toBeInTheDocument()
expect(screen.getByText(tx('docsync.connected'))).toBeInTheDocument()
expect(testButton()).toBeEnabled()
})
})
describe('DocSyncConnectModal saving', () => {
it('FE-DOCSYNC-CONNECT-020: a successful save reports the connected provider upward', async () => {
const { onConnected, onClose } = await open()
fillNextcloud()
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalledWith('nextcloud'))
expect(onConnected).toHaveBeenCalledTimes(1)
expect(onClose).not.toHaveBeenCalled()
// the freshly stored connection is read back, not assumed
expect(listConnections).toHaveBeenCalledTimes(2)
})
it('FE-DOCSYNC-CONNECT-021: a rejected save reports nothing upward and leaves the form standing', async () => {
saveConnection.mockRejectedValueOnce({ response: { data: { error: 'unauthorized' } } })
const { onConnected, onClose } = await open()
fillNextcloud()
fireEvent.click(connectButton())
await waitFor(() => expect(saveConnection).toHaveBeenCalled())
expect(onConnected).not.toHaveBeenCalled()
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByPlaceholderText('app password')).toHaveValue('app-pw')
})
it('FE-DOCSYNC-CONNECT-022: the save button is locked while the write is in flight', async () => {
let settle: (value: unknown) => void = () => {}
saveConnection.mockImplementationOnce(() => new Promise(resolve => { settle = resolve }))
const { onConnected } = await open()
fillNextcloud()
fireEvent.click(connectButton())
expect(connectButton()).toBeDisabled()
await act(async () => {
settle({ id: 1 })
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(onConnected).toHaveBeenCalledWith('nextcloud')
})
it('FE-DOCSYNC-CONNECT-023: the self-signed switch starts off and travels with the request', async () => {
await open()
fillNextcloud()
fireEvent.click(screen.getByRole('button', { pressed: false }))
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument()
fireEvent.click(connectButton())
await waitFor(() => expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ allowInsecureTls: true })))
})
})
describe('DocSyncConnectModal with a connection already stored', () => {
it('FE-DOCSYNC-CONNECT-024: the stored settings come back, the saved secret is only hinted at, and it saves untouched', async () => {
listConnections.mockResolvedValue([connection({ settings: { login_name: 'maurice', base_path: '/Archive/TREK', app_password: 'never-echo-me' } })])
const { onConnected } = await open(nextcloud, true)
expect(await screen.findByPlaceholderText('••••••••')).toBeInTheDocument()
expect(screen.getByPlaceholderText('username')).toHaveValue('maurice')
expect(screen.getByPlaceholderText('/TREK')).toHaveValue('/Archive/TREK')
// a secret is never echoed back into the box, whatever the settings blob holds
expect(screen.getByPlaceholderText('••••••••')).toHaveValue('')
// the stored secret counts as filled, so nothing has to be retyped
expect(connectButton()).toBeEnabled()
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalledWith('nextcloud'))
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example.com',
credentials: {},
allowInsecureTls: false,
})
})
it('FE-DOCSYNC-CONNECT-025: the stored self-signed choice is the one the switch starts on', async () => {
listConnections.mockResolvedValue([connection({ allowInsecureTls: true })])
await open(nextcloud, true)
expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument()
})
it('FE-DOCSYNC-CONNECT-026: a connection that lands after the dialog is open still sets the switch', async () => {
// The switch used to be seeded by a useState initialiser, so it only ever
// read the connection that existed at the moment the dialog mounted: a
// stored "allow self-signed" showed as off, and the next save wrote that
// back. A security switch turning itself off unasked is the bad version of
// a stale field, so it now reads through until somebody touches it.
listConnections.mockResolvedValue([connection({ allowInsecureTls: true })])
await open(nextcloud, false)
await screen.findByPlaceholderText('••••••••')
await waitFor(() => expect(screen.getByRole('button', { pressed: true })).toBeInTheDocument())
})
it('FE-DOCSYNC-CONNECT-027: the stored address is shown as well as submitted', async () => {
listConnections.mockResolvedValue([connection()])
const { onConnected } = await open(nextcloud, true)
await screen.findByPlaceholderText('••••••••')
// The address is a column of its own rather than a settings entry. Reading
// it out of settings left the box empty over its placeholder while the form
// went on submitting the stored value — the field and the request quietly
// disagreeing about what the connection is.
await waitFor(() =>
expect(screen.getByPlaceholderText('https://cloud.example.com')).toHaveValue('https://cloud.example.com'),
)
fireEvent.click(connectButton())
await waitFor(() => expect(onConnected).toHaveBeenCalled())
expect(saveConnection).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({ baseUrl: 'https://cloud.example.com' }))
})
})
@@ -0,0 +1,197 @@
import { useState } from 'react'
import { AlertTriangle, Check, Loader2, ShieldAlert } from 'lucide-react'
import Modal from '../../shared/Modal'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useTranslation } from '../../../i18n/TranslationContext'
import { Badge } from './DocSyncBits'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import type { DocSyncProvider, useDocSync } from './useDocSync'
/**
* Connecting a store, as a dialog rather than a panel that unfolds in place.
*
* Entering a URL and a token is a task with a beginning and an end, and doing it
* inside the list meant the list jumped around while someone typed. The dialog
* also gives the probe result somewhere to live that is not a line of text
* squeezed between two fields.
*/
export default function DocSyncConnectModal({
provider,
sync,
onClose,
onConnected,
}: {
provider: DocSyncProvider
sync: ReturnType<typeof useDocSync>
onClose: () => void
onConnected: (providerId: string) => void
}) {
const { t } = useTranslation()
const existing = sync.connectionFor(provider.id)
const Icon = DOCUMENT_PROVIDER_ICONS[provider.id]
const [values, setValues] = useState<Record<string, string>>({})
const [insecure, setInsecure] = useState<boolean | null>(null)
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
// Null until touched, so a connection that loads after the dialog opened is
// still reflected. Seeding the state at mount showed a stored "allow
// self-signed" as off and wrote that back on the next save — a security
// switch silently turning itself off is the worst version of this bug.
const insecureOn = insecure ?? existing?.allowInsecureTls ?? false
const baseUrl = values.base_url ?? existing?.baseUrl ?? ''
// The address is a column of its own, not a settings entry, so it has to be
// read from there — otherwise the field sits empty while the form submits the
// stored value behind it.
const shown = (key: string) =>
values[key] ?? (key === 'base_url' ? existing?.baseUrl : existing?.settings?.[key]) ?? ''
const required = provider.fields.filter(f => f.required)
const canSubmit = required.every(f =>
f.field_key === 'base_url'
? baseUrl.trim().length > 0
: f.secret
? (values[f.field_key] ?? '').length > 0 || !!existing?.secrets?.[f.field_key]
: shown(f.field_key).trim().length > 0,
)
const test = async () => setVerdict(await sync.testConnection(provider.id, baseUrl, values, insecureOn))
const save = async () => {
if (await sync.saveConnection(provider.id, baseUrl, values, insecureOn)) onConnected(provider.id)
}
return (
<Modal
isOpen
onClose={onClose}
size="md"
title={
<span className="flex items-center gap-2.5">
{Icon && <Icon className="h-5 w-5 text-content" />}
<span>{provider.name}</span>
</span>
}
footer={
<div className="flex items-center justify-between gap-3">
<Verdict verdict={verdict} busy={sync.busy === 'test'} />
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => void test()}
disabled={!canSubmit || sync.busy === 'test'}
className="rounded-lg border border-edge px-3.5 py-2 text-body text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
>
{t('docsync.test')}
</button>
<button
type="button"
onClick={() => void save()}
disabled={!canSubmit || sync.busy === 'save'}
className="flex items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-body font-medium text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{sync.busy === 'save' && <Loader2 size={14} className="animate-spin" />}
{t('docsync.connect.submit')}
</button>
</div>
</div>
}
>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t(`docsync.connect.about.${provider.id}`)}</p>
{provider.fields.filter(f => f.field_key !== 'allow_insecure_tls').map(f => (
<Field
key={f.field_key}
label={t(`docsync.${f.label}`)}
hint={f.hint ? t(`docsync.${f.hint}`) : undefined}
required={f.required}
>
<input
type={f.input_type === 'password' ? 'password' : 'text'}
value={f.secret ? (values[f.field_key] ?? '') : shown(f.field_key)}
// A stored secret never comes back, so an empty field with a dotted
// placeholder means "the one already saved" rather than "blank".
placeholder={f.secret && existing?.secrets?.[f.field_key] ? '••••••••' : f.placeholder || ''}
onChange={e => setValues({ ...values, [f.field_key]: e.target.value })}
className="w-full rounded-lg border border-edge bg-surface-input px-3 py-2.5 text-body text-content ring-accent transition-shadow placeholder:text-content-faint focus:outline-none focus:ring-2"
/>
</Field>
))}
<label className="flex items-start justify-between gap-4 rounded-lg border border-edge bg-surface-secondary px-3 py-2.5">
<span className="min-w-0">
<span className="block text-body text-content">{t('docsync.allowInsecureTls')}</span>
<span className="mt-0.5 block text-caption text-content-muted">
{t('docsync.connect.insecureHint')}
</span>
</span>
<span className="shrink-0 pt-0.5">
<ToggleSwitch on={insecureOn} onToggle={() => setInsecure(!insecureOn)} />
</span>
</label>
</div>
</Modal>
)
}
function Field({
label,
hint,
required,
children,
}: {
label: string
hint?: string
required?: boolean
children: React.ReactNode
}) {
const { t } = useTranslation()
return (
<label className="block">
<span className="mb-1.5 flex items-center gap-2">
<span className="text-body font-medium text-content">{label}</span>
{!required && <Badge tone="neutral">{t('docsync.connect.optional')}</Badge>}
</span>
{children}
{hint && <span className="mt-1.5 block text-caption text-content-muted">{hint}</span>}
</label>
)
}
/** The probe result, in the footer where it stays put while the form scrolls. */
function Verdict({
verdict,
busy,
}: {
verdict: { connected: boolean; account?: string; error?: string } | null
busy: boolean
}) {
const { t } = useTranslation()
if (busy) {
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-content-muted">
<Loader2 size={14} className="animate-spin" />
{t('docsync.connect.testing')}
</span>
)
}
if (!verdict) return <span />
if (verdict.connected) {
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-success">
<Check size={14} strokeWidth={2.5} />
<span className="truncate">
{verdict.account ? t('docsync.connect.okAs', { account: verdict.account }) : t('docsync.connected')}
</span>
</span>
)
}
const blocked = verdict.error === 'ssrf_blocked' || verdict.error === 'tls_untrusted'
return (
<span className="flex min-w-0 items-center gap-2 text-caption text-danger">
{blocked ? <ShieldAlert size={14} /> : <AlertTriangle size={14} />}
<span className="truncate">{t(`docsync.error.${verdict.error || 'unknown'}`)}</span>
</span>
)
}
@@ -0,0 +1,214 @@
// FE-DOCSYNC-FLOW-001 to FE-DOCSYNC-FLOW-013
/**
* The direction control of the document-sync dialog.
*
* Two lanes, each one a direction that is either carrying or idle. The rules
* that matter to somebody using it: what each side is holding is readable
* before a word is, switching direction means switching a lane off, and the
* last lane cannot be switched off, a binding with no direction syncs
* nothing, and a dialog that lets you build one has lied about what it does.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent } from '../../../../tests/helpers/render'
import DocSyncFlow, { type FlowHoldings, type SyncDirection } from './DocSyncFlow'
// The captions come from the locale rather than from English typed out here:
// the assertion is "the lane labelled as the outbound one", not the wording.
const S = en as unknown as Record<string, string>
const OUT = S['docsync.flow.toProvider']
const IN = S['docsync.flow.toTrek']
const onChange = vi.fn()
const holdings: FlowHoldings = { inTrek: 12, atProvider: 7, paired: 5, missing: 2 }
function renderFlow(
props: Partial<{
direction: SyncDirection
providerId: string
providerName: string
holdings: FlowHoldings
running: boolean
disabled: boolean
}> = {},
) {
return render(
<DocSyncFlow
direction="both"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
{...props}
/>,
)
}
const laneOut = () => screen.getByRole('button', { name: OUT })
const laneIn = () => screen.getByRole('button', { name: IN })
beforeEach(() => {
vi.clearAllMocks()
})
describe('DocSyncFlow ends', () => {
it('FE-DOCSYNC-FLOW-001: shows what each side is holding, next to the name of that side', () => {
renderFlow()
expect(screen.getByText('12')).toBeInTheDocument()
expect(screen.getByText('7')).toBeInTheDocument()
expect(screen.getByText(S['docsync.flow.trek'])).toBeInTheDocument()
expect(screen.getByText('Paperless')).toBeInTheDocument()
})
it('FE-DOCSYNC-FLOW-002: an empty side reads as zero rather than as nothing', () => {
// A healthy binding transfers nothing; the panel must still answer the
// question somebody opened it for.
renderFlow({ holdings: { inTrek: 0, atProvider: 0, paired: 0, missing: 0 } })
expect(screen.getAllByText('0')).toHaveLength(2)
})
it('FE-DOCSYNC-FLOW-003: an unrecognised store still names itself', () => {
renderFlow({ providerId: 'not-a-provider', providerName: 'Some Store' })
expect(screen.getByText('Some Store')).toBeInTheDocument()
expect(laneOut()).toBeInTheDocument()
})
})
describe('DocSyncFlow direction switching', () => {
it('FE-DOCSYNC-FLOW-004: switching the outbound lane off from both leaves inbound only', () => {
renderFlow({ direction: 'both' })
fireEvent.click(laneOut())
expect(onChange).toHaveBeenCalledWith('pull')
})
it('FE-DOCSYNC-FLOW-005: switching the inbound lane off from both leaves outbound only', () => {
renderFlow({ direction: 'both' })
fireEvent.click(laneIn())
expect(onChange).toHaveBeenCalledWith('push')
})
it('FE-DOCSYNC-FLOW-006: switching the idle inbound lane on from push gives both ways', () => {
renderFlow({ direction: 'push' })
fireEvent.click(laneIn())
expect(onChange).toHaveBeenCalledWith('both')
})
it('FE-DOCSYNC-FLOW-007: switching the idle outbound lane on from pull gives both ways', () => {
renderFlow({ direction: 'pull' })
fireEvent.click(laneOut())
expect(onChange).toHaveBeenCalledWith('both')
})
it('FE-DOCSYNC-FLOW-008: the last carrying lane refuses to switch off (push)', () => {
renderFlow({ direction: 'push' })
fireEvent.click(laneOut())
expect(onChange).not.toHaveBeenCalled()
expect(laneOut()).toHaveAttribute('aria-pressed', 'true')
})
it('FE-DOCSYNC-FLOW-009: the last carrying lane refuses to switch off (pull)', () => {
renderFlow({ direction: 'pull' })
fireEvent.click(laneIn())
expect(onChange).not.toHaveBeenCalled()
expect(laneIn()).toHaveAttribute('aria-pressed', 'true')
})
it('FE-DOCSYNC-FLOW-010: a member who may not edit the binding cannot move the lanes', () => {
renderFlow({ direction: 'both', disabled: true })
expect(laneOut()).toBeDisabled()
expect(laneIn()).toBeDisabled()
fireEvent.click(laneOut())
fireEvent.click(laneIn())
expect(onChange).not.toHaveBeenCalled()
})
})
describe('DocSyncFlow lane state', () => {
it('FE-DOCSYNC-FLOW-011: aria-pressed mirrors each lane in every direction', () => {
const { rerender } = renderFlow({ direction: 'both' })
expect(laneOut()).toHaveAttribute('aria-pressed', 'true')
expect(laneIn()).toHaveAttribute('aria-pressed', 'true')
rerender(
<DocSyncFlow
direction="push"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
expect(laneOut()).toHaveAttribute('aria-pressed', 'true')
expect(laneIn()).toHaveAttribute('aria-pressed', 'false')
rerender(
<DocSyncFlow
direction="pull"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
expect(laneOut()).toHaveAttribute('aria-pressed', 'false')
expect(laneIn()).toHaveAttribute('aria-pressed', 'true')
})
it('FE-DOCSYNC-FLOW-012: only the switched-off lane is hatched', () => {
// The hatch is the whole of "this road is not driven on", there is no
// other handle for it, so the class is the behaviour here.
const { rerender } = renderFlow({ direction: 'both' })
expect(laneOut()).not.toHaveClass('trek-docsync-lane-off')
expect(laneIn()).not.toHaveClass('trek-docsync-lane-off')
rerender(
<DocSyncFlow
direction="push"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
onChange={onChange}
/>,
)
expect(laneOut()).not.toHaveClass('trek-docsync-lane-off')
expect(laneIn()).toHaveClass('trek-docsync-lane-off')
})
it('FE-DOCSYNC-FLOW-013: the hint tells an editor the lanes are tappable, a reader only what happens', () => {
const { rerender } = renderFlow({ direction: 'both' })
expect(screen.getByText(S['docsync.flow.summaryEditable.both'])).toBeInTheDocument()
rerender(
<DocSyncFlow
direction="both"
providerId="paperless"
providerName="Paperless"
holdings={holdings}
disabled
onChange={onChange}
/>,
)
expect(screen.getByText(S['docsync.flow.summary.both'])).toBeInTheDocument()
expect(screen.queryByText(S['docsync.flow.summaryEditable.both'])).not.toBeInTheDocument()
})
})
@@ -0,0 +1,213 @@
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { useTranslation } from '../../../i18n/TranslationContext'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import TrekMark from '../../shared/TrekMark'
import { Badge } from './DocSyncBits'
export type SyncDirection = 'both' | 'pull' | 'push'
export interface FlowHoldings {
/** Documents this trip has in TREK. */
inTrek: number
/** How many of them the store is holding. */
atProvider: number
paired: number
missing: number
}
/**
* The direction control, drawn as what it actually is.
*
* A document sync has one characteristic picture — two places and things moving
* between them — and a dropdown reading "Both ways" hides it behind a word.
* Here the two ends carry what each side is holding, the lanes between them
* carry the direction, and switching direction means switching a lane off. It
* is the one loud element in the dialog; everything around it stays quiet.
*
* The numbers are standing counts, not the last run's transfers: a binding that
* is already in step transferred nothing, and a panel answering "0" on a
* healthy connection says nothing at the moment somebody looks at it.
*/
export default function DocSyncFlow({
direction,
providerId,
providerName,
holdings,
running,
disabled,
onChange,
}: {
direction: SyncDirection
providerId: string
providerName: string
holdings: FlowHoldings
/** A run is in flight: the active lanes move while it is. */
running?: boolean
disabled?: boolean
onChange: (next: SyncDirection) => void
}) {
const { t } = useTranslation()
const Icon = DOCUMENT_PROVIDER_ICONS[providerId]
const pushOn = direction === 'both' || direction === 'push'
const pullOn = direction === 'both' || direction === 'pull'
const hint = disabled
? t(`docsync.flow.summary.${direction}`, { provider: providerName })
: t(`docsync.flow.summaryEditable.${direction}`, { provider: providerName })
/** Turning a lane off leaves the other one; turning the last one off is refused. */
const toggle = (lane: 'push' | 'pull') => {
if (disabled) return
const nextPush = lane === 'push' ? !pushOn : pushOn
const nextPull = lane === 'pull' ? !pullOn : pullOn
if (!nextPush && !nextPull) return
onChange(nextPush && nextPull ? 'both' : nextPush ? 'push' : 'pull')
}
return (
<section className="rounded-2xl border border-edge bg-surface-secondary p-4 sm:p-6">
<div className="flex items-center gap-3 sm:gap-5">
<End
name={t('docsync.flow.trek')}
count={holdings.inTrek}
glyph={<TrekMark className="h-6 w-6 text-content" />}
/>
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<Lane
active={pushOn}
running={running}
disabled={disabled}
caption={t('docsync.flow.toProvider')}
onClick={() => toggle('push')}
>
<ArrowRight size={13} strokeWidth={2.75} />
</Lane>
<Lane
active={pullOn}
running={running}
reverse
disabled={disabled}
caption={t('docsync.flow.toTrek')}
onClick={() => toggle('pull')}
>
<ArrowLeft size={13} strokeWidth={2.75} />
</Lane>
{/* Under the lanes rather than under the whole bar: it explains what
the two buttons above it are doing, and sitting anywhere else
makes the reader look for what it refers to. */}
<p className="truncate px-1 text-center text-caption text-content-muted">{hint}</p>
</div>
<End
name={providerName}
count={holdings.atProvider}
glyph={Icon ? <Icon className="h-6 w-6 text-content" /> : null}
/>
</div>
</section>
)
}
/**
* One end of the flow: the mark, the count, the name.
*
* The count is the largest thing in the box on purpose — "is my stuff over
* there" is the question that makes somebody open this dialog, and it should be
* answered before they read a word.
*/
function End({ name, count, glyph }: { name: string; count: number; glyph: React.ReactNode }) {
const { t } = useTranslation()
return (
<div
className="flex w-24 shrink-0 flex-col items-center gap-2 rounded-2xl border border-edge bg-surface px-3 py-4 shadow-card sm:w-32"
>
<span className="grid h-10 w-10 place-items-center rounded-xl bg-surface-secondary">{glyph}</span>
<span className="flex flex-col items-center gap-1">
<span className="text-title font-semibold tabular-nums leading-none text-content">{count}</span>
<span className="text-caption uppercase leading-none tracking-wide text-content-faint">
{t('docsync.flow.documents')}
</span>
</span>
<Badge tone="neutral" title={name}>
<span className="max-w-[6.5rem] truncate">{name}</span>
</Badge>
</div>
)
}
/**
* One direction, as a track that is either carrying or idle.
*
* An idle track stays visible as a dashed line, so switching a direction off
* reads as a road nobody drives on rather than as something that disappeared.
*/
function Lane({
active,
running,
disabled,
caption,
reverse,
onClick,
children,
}: {
active: boolean
running?: boolean
disabled?: boolean
caption: string
reverse?: boolean
onClick: () => void
children: React.ReactNode
}) {
const moving = active && running
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={active}
className={[
// No border at all, not a transparent one: a border box across the
// diagonals still renders a hairline where it meets the corner radius,
// and that line reads as a broken frame rather than as a hatch.
'group relative flex h-12 items-center gap-3 overflow-hidden rounded-xl px-3 text-caption transition-all duration-200',
reverse ? 'flex-row-reverse' : '',
active
? 'bg-accent-subtle text-accent-on'
: 'trek-docsync-lane-off bg-surface text-content-faint',
disabled ? 'cursor-default' : active ? 'hover:brightness-[0.98]' : 'hover:text-content-muted',
].join(' ')}
>
<span
className={[
'relative grid h-7 w-7 shrink-0 place-items-center rounded-lg transition-transform duration-200',
active ? 'bg-surface shadow-sm' : 'bg-surface-secondary',
disabled ? '' : reverse ? 'group-hover:-translate-x-0.5' : 'group-hover:translate-x-0.5',
].join(' ')}
>
{children}
</span>
{/* Explicitly aligned to the arrow's side: the dialog inherits a centred
text-align, which would otherwise float the label away from it. */}
<span className={`relative min-w-0 flex-1 truncate font-medium ${reverse ? 'text-right' : 'text-left'}`}>
{caption}
</span>
{/* Only while a run is in flight. An idle lane needs no track — that it is
on or off is already in its fill — and a permanent line just asks the
reader what it means. */}
{moving && (
<span
aria-hidden
className={`trek-docsync-lane-run pointer-events-none absolute inset-x-3 bottom-1.5 h-0.5 rounded-full ${
reverse ? 'trek-docsync-lane-run-rev' : ''
}`}
/>
)}
</button>
)
}
@@ -0,0 +1,370 @@
// FE-DOCSYNC-PANEL-001 to FE-DOCSYNC-PANEL-016
/**
* The document-sync dialog shell (#2391).
*
* The panel itself owns three decisions: which store is open, which of them a
* person is even offered, and whether the "needs a look" list is worth the
* space. Everything below it — the binding card, the flow bar, the connect and
* scope modals — is tested where it lives; what is exercised here is the shell
* around them, driven through the real `useDocSync` against a mocked
* `docsyncApi`, because the selection rules are a reaction to what the server
* answers rather than to a prop.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { act, render, screen, within, fireEvent, waitFor } from '../../../../tests/helpers/render'
/** What the dialog actually renders for a key — the same fallback chain `t()` uses. */
const t = (key: string): string => (en as unknown as Record<string, string>)[key] ?? key
const providers = vi.fn()
const listConnections = vi.fn()
const listLinks = vi.fn()
const status = vi.fn()
const deleteLink = vi.fn()
const updateLink = vi.fn()
const syncNow = vi.fn()
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: (tripId: number | string) => providers(tripId),
listConnections: (tripId: number | string) => listConnections(tripId),
listLinks: (tripId: number | string) => listLinks(tripId),
status: (tripId: number | string) => status(tripId),
deleteLink: (tripId: number | string, linkId: number) => deleteLink(tripId, linkId),
updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLink(tripId, linkId, patch),
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNow(tripId, linkId, full),
},
}
})
import DocSyncPanel from './DocSyncPanel'
import type { DocSyncLink, DocSyncProvider } from './useDocSync'
const provider = (id: string, name: string, available = true): DocSyncProvider => ({
id,
name,
description: null,
icon: id,
available,
fields: [],
})
const link = (id: number, providerId: string, overrides: Partial<DocSyncLink> = {}): DocSyncLink => ({
id,
connectionId: id,
providerId,
scopeKey: `scope-${id}`,
remoteLabel: `Folder ${id}`,
remoteRootPath: `/TREK/folder-${id}`,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'ok',
lastSyncError: null,
webhookUrl: null,
...overrides,
})
/** What the server answers, as one object the tests reshape per case. */
function serverHas({
providers: p = [],
links = [],
items = {},
}: {
providers?: DocSyncProvider[]
links?: DocSyncLink[]
items?: Record<string, number>
}): void {
providers.mockResolvedValue(p)
listConnections.mockResolvedValue([])
listLinks.mockResolvedValue(links)
status.mockResolvedValue({
items,
links: links.map(l => ({ id: l.id, holdings: { inTrek: 3, atProvider: 3, paired: 3, missing: 0 } })),
})
}
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(r => { resolve = r })
return { promise, resolve }
}
const onClose = vi.fn()
/** The detail column's binding card. */
const binding = () => screen.getByRole('article')
/** The store list beside it. */
const sidebar = () => screen.getByRole('navigation')
/** The stores filed under one sidebar heading. */
const group = (heading: string) =>
within(screen.getByRole('heading', { name: heading }).parentElement as HTMLElement)
beforeEach(() => {
vi.clearAllMocks()
serverHas({})
deleteLink.mockResolvedValue({})
updateLink.mockResolvedValue({})
syncNow.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
})
describe('DocSyncPanel — first load', () => {
it('FE-DOCSYNC-PANEL-001: shows a spinner while the first load is in flight and nothing else', async () => {
const gate = deferred<DocSyncProvider[]>()
providers.mockReturnValue(gate.promise)
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
// The spinner carries no text of its own; the spin is the whole affordance.
expect(document.querySelector('.animate-spin')).not.toBeNull()
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
expect(screen.queryByText(t('docsync.noProviders'))).not.toBeInTheDocument()
await act(async () => { gate.resolve([]) })
})
it('FE-DOCSYNC-PANEL-002: replaces the spinner with the store list once the load lands', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')] })
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
expect(await screen.findByRole('navigation')).toBeInTheDocument()
expect(document.querySelector('.animate-spin')).toBeNull()
})
it('FE-DOCSYNC-PANEL-003: says there is nothing to configure when the instance offers no provider', async () => {
serverHas({ providers: [] })
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument()
expect(screen.getByText(t('docsync.noProvidersHint'))).toBeInTheDocument()
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-004: a provider switched on without a working adapter is not offered', async () => {
serverHas({ providers: [provider('papra', 'Papra', false)] })
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
expect(await screen.findByText(t('docsync.noProviders'))).toBeInTheDocument()
expect(screen.queryByText('Papra')).not.toBeInTheDocument()
})
})
describe('DocSyncPanel — the store list', () => {
it('FE-DOCSYNC-PANEL-005: files the bound stores under one heading and the rest under another', async () => {
serverHas({
providers: [
provider('paperless', 'Paperless-ngx'),
provider('nextcloud', 'Nextcloud'),
provider('opencloud', 'OpenCloud'),
],
links: [link(1, 'paperless')],
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
await screen.findByRole('navigation')
const connected = group(t('docsync.sidebar.connected'))
expect(connected.getByRole('button', { name: /Paperless-ngx/ })).toBeInTheDocument()
expect(connected.queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument()
const addable = group(t('docsync.addAnother'))
expect(addable.getByRole('button', { name: /Nextcloud/ })).toBeInTheDocument()
expect(addable.getByRole('button', { name: /OpenCloud/ })).toBeInTheDocument()
expect(addable.queryByRole('button', { name: /Paperless-ngx/ })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-006: with nothing bound the list invites a first connection instead of another one', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] })
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
await screen.findByRole('navigation')
expect(screen.getByRole('heading', { name: t('docsync.addProvider') })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: t('docsync.addAnother') })).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: t('docsync.sidebar.connected') })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-007: opens the first bound store without being asked', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
const card = await screen.findByRole('article')
// The card names its provider twice (header and flow bar), so the folder is
// what says which of the two bindings is open.
expect(within(card).getByText('Folder 1')).toBeInTheDocument()
expect(within(card).queryByText('Folder 2')).not.toBeInTheDocument()
expect(within(card).getAllByText('Paperless-ngx').length).toBeGreaterThan(0)
expect(within(card).queryByText('Nextcloud')).not.toBeInTheDocument()
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toHaveAttribute('aria-current', 'true')
})
it('FE-DOCSYNC-PANEL-008: picking a store swaps the detail column and moves the marker with it', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless'), link(2, 'nextcloud')],
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
await screen.findByRole('article')
const second = within(sidebar()).getByRole('button', { name: /Nextcloud/ })
fireEvent.click(second)
expect(within(binding()).getByText('Folder 2')).toBeInTheDocument()
expect(within(binding()).queryByText('Folder 1')).not.toBeInTheDocument()
expect(within(binding()).getAllByText('Nextcloud').length).toBeGreaterThan(0)
expect(within(binding()).queryByText('Paperless-ngx')).not.toBeInTheDocument()
expect(second).toHaveAttribute('aria-current', 'true')
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).not.toHaveAttribute('aria-current')
})
})
describe('DocSyncPanel — a member rather than the owner', () => {
it('FE-DOCSYNC-PANEL-009: is shown where the documents go but is offered no store to add', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')],
links: [link(1, 'paperless')],
})
render(<DocSyncPanel tripId={7} isOwner={false} onClose={onClose} />)
await screen.findByRole('navigation')
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: t('docsync.addAnother') })).not.toBeInTheDocument()
expect(within(sidebar()).queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-010: with nothing bound is told the owner sets this up, not to pick a store', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] })
render(<DocSyncPanel tripId={7} isOwner={false} onClose={onClose} />)
expect(await screen.findByText(t('docsync.empty.hintMember'))).toBeInTheDocument()
expect(screen.queryByText(t('docsync.empty.hintOwner'))).not.toBeInTheDocument()
expect(within(sidebar()).queryAllByRole('button')).toHaveLength(0)
})
})
describe('DocSyncPanel — removing a binding', () => {
it('FE-DOCSYNC-PANEL-011: moves the selection to the binding that is left instead of emptying the column', async () => {
const paperless = link(1, 'paperless')
const nextcloud = link(2, 'nextcloud')
const both = [provider('paperless', 'Paperless-ngx'), provider('nextcloud', 'Nextcloud')]
serverHas({ providers: both, links: [paperless, nextcloud] })
// The unlink lands on the server, and the reload after it is what the panel
// reacts to — so that is where the row disappears.
deleteLink.mockImplementation(async () => {
serverHas({ providers: both, links: [paperless] })
return {}
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(sidebar()).getByRole('button', { name: /Nextcloud/ }))
expect(within(binding()).getByText('Folder 2')).toBeInTheDocument()
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') }))
await waitFor(() => expect(deleteLink).toHaveBeenCalledWith(7, 2))
await waitFor(() => expect(within(binding()).getByText('Folder 1')).toBeInTheDocument())
expect(screen.queryByText(t('docsync.empty.title'))).not.toBeInTheDocument()
expect(within(sidebar()).getByRole('button', { name: /Paperless-ngx/ })).toHaveAttribute('aria-current', 'true')
})
it('FE-DOCSYNC-PANEL-012: removing the last binding leaves the invitation, not a blank column', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')] })
deleteLink.mockImplementation(async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [] })
return {}
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
await screen.findByRole('article')
fireEvent.click(within(binding()).getByRole('button', { name: t('docsync.unlink') }))
expect(await screen.findByText(t('docsync.empty.title'))).toBeInTheDocument()
expect(screen.getByText(t('docsync.empty.hintOwner'))).toBeInTheDocument()
expect(screen.queryByRole('article')).not.toBeInTheDocument()
})
})
describe('DocSyncPanel — what needs a look', () => {
it('FE-DOCSYNC-PANEL-013: stays quiet when nothing needs attention', async () => {
serverHas({ providers: [provider('paperless', 'Paperless-ngx')], links: [link(1, 'paperless')], items: {} })
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
await screen.findByRole('article')
expect(screen.queryByRole('heading', { name: t('docsync.issues.title') })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-014: counts that need no decision do not open the issues panel', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
links: [link(1, 'paperless')],
items: { pending: 9, synced: 41, local_deleted: 2 },
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
await screen.findByRole('article')
expect(screen.queryByRole('heading', { name: t('docsync.issues.title') })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-PANEL-015: names only the states needing attention, with their counts', async () => {
serverHas({
providers: [provider('paperless', 'Paperless-ngx')],
links: [link(1, 'paperless')],
items: { conflict: 2, too_large: 1, pending: 9, synced: 41 },
})
render(<DocSyncPanel tripId={7} isOwner onClose={onClose} />)
await screen.findByRole('article')
const panel = within(
screen.getByRole('heading', { name: t('docsync.issues.title') }).closest('section') as HTMLElement,
)
const rows = panel.getAllByRole('listitem')
expect(rows).toHaveLength(2)
expect(within(rows[0]).getByText(t('docsync.state.conflict'))).toBeInTheDocument()
expect(within(rows[0]).getByText(t('docsync.issues.conflict'))).toBeInTheDocument()
expect(within(rows[0]).getByText('2')).toBeInTheDocument()
expect(within(rows[1]).getByText(t('docsync.state.too_large'))).toBeInTheDocument()
expect(within(rows[1]).getByText('1')).toBeInTheDocument()
expect(panel.queryByText(t('docsync.state.pending'))).not.toBeInTheDocument()
})
})
describe('DocSyncPanel — the dialog itself', () => {
it('FE-DOCSYNC-PANEL-016: names the trip under the title and closes from the header', async () => {
serverHas({ providers: [] })
render(<DocSyncPanel tripId={7} tripTitle="Iceland 2026" isOwner onClose={onClose} />)
await screen.findByText(t('docsync.noProviders'))
expect(screen.getByText(t('docsync.title'))).toBeInTheDocument()
expect(screen.getByText('Iceland 2026')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button'))
expect(onClose).toHaveBeenCalledTimes(1)
})
})
@@ -1,343 +1,332 @@
import { useState } from 'react'
import { AlertTriangle, Check, Cloud, FolderSync, Loader2, Plus, RefreshCw, Trash2, X } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { AlertCircle, ArrowRight, Check, Loader2, Plus } from 'lucide-react'
import Modal from '../../shared/Modal'
import { useTranslation } from '../../../i18n/TranslationContext'
import ToggleSwitch from '../../Settings/ToggleSwitch'
import { useDocSync, type DocSyncProvider, type DocSyncScope } from './useDocSync'
import { DOCUMENT_PROVIDER_ICONS } from '../../shared/DocumentProviderIcons'
import { StateBadge } from './DocSyncBits'
import { useDocSync, type DocSyncLink, type DocSyncProvider } from './useDocSync'
import DocSyncBinding from './DocSyncBinding'
import DocSyncConnectModal from './DocSyncConnectModal'
import DocSyncScopeModal from './DocSyncScopeModal'
/**
* The trip's document sync, configured where the documents are.
* Document sync for one trip, as a dialog with the stores down one side and the
* selected one open beside it.
*
* 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.
* Two columns rather than a stack, because the two questions are different
* shapes: "which store" is a short list somebody scans, and "how does this one
* behave" is a screenful. Stacked, the second answer pushed the first one off
* the top every time a binding was added.
*
* It opens from the file manager rather than from settings: this is the one
* screen where a person has the context that makes the folder choice obvious.
* Only the trip owner can change a binding — the credential usually reaches
* their whole archive — but every member sees 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,
export default function DocSyncPanel({
tripId,
tripTitle,
isOwner,
onClose,
}: {
provider: DocSyncProvider
tripId: number | string
sync: ReturnType<typeof useDocSync>
open: boolean
onOpen: () => void
tripTitle?: string
isOwner: boolean
onClose: () => 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 sync = useDocSync(tripId, true)
const [selected, setSelected] = useState<number | null>(null)
const [connecting, setConnecting] = useState<DocSyncProvider | null>(null)
const [scopeFor, setScopeFor] = useState<string | null>(null)
const baseUrl = values.base_url ?? existing?.baseUrl ?? ''
const field = (key: string) => values[key] ?? existing?.settings?.[key] ?? ''
const bound = useMemo(() => new Set(sync.links.map(l => l.providerId)), [sync.links])
const available = sync.providers.filter(p => !bound.has(p.id))
const active = sync.links.find(l => l.id === selected) ?? sync.links[0] ?? null
const runTest = async () => {
const res = await sync.testConnection(provider.id, baseUrl, values, insecure)
setVerdict(res)
}
// Follow the list: a freshly bound store should be the one on screen, and a
// removed one must not leave the detail column pointing at nothing.
useEffect(() => {
if (sync.links.length === 0) setSelected(null)
else if (!sync.links.some(l => l.id === selected)) setSelected(sync.links[0].id)
}, [sync.links, selected])
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)
}
const attention = ATTENTION_STATES.reduce((n, k) => n + (sync.itemCounts[k] ?? 0), 0)
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>
<>
<Modal
isOpen
onClose={onClose}
size="4xl"
title={
<span className="flex flex-col">
<span>{t('docsync.title')}</span>
{tripTitle && (
<span className="mt-0.5 truncate text-caption font-normal text-content-muted">{tripTitle}</span>
)}
</span>
}
>
{sync.loading ? (
<div className="grid place-items-center py-20">
<Loader2 size={22} className="animate-spin text-content-faint" />
</div>
) : sync.providers.length === 0 ? (
<Empty />
) : (
<div className="grid gap-5 md:grid-cols-[17rem_minmax(0,1fr)] md:gap-6">
<Sidebar
links={sync.links}
providers={sync.providers}
available={available}
activeId={active?.id ?? null}
isOwner={isOwner}
onSelect={setSelected}
onAdd={p => (sync.connectionFor(p.id) ? setScopeFor(p.id) : setConnecting(p))}
/>
{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 className="min-w-0 md:border-l md:border-edge-faint md:pl-6">
{active ? (
<div className="space-y-5">
<DocSyncBinding
link={active}
provider={sync.providers.find(p => p.id === active.providerId)}
sync={sync}
isOwner={isOwner}
/>
{attention > 0 && <Attention counts={sync.itemCounts} />}
</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"
/>
<NothingBound isOwner={isOwner} />
)}
{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>
)}
</div>
)}
</Modal>
{sync.error && <p className="text-caption text-danger">{t(`docsync.error.${sync.error}`)}</p>}
{connecting && (
<DocSyncConnectModal
provider={connecting}
sync={sync}
onClose={() => setConnecting(null)}
onConnected={id => { setConnecting(null); setScopeFor(id) }}
/>
)}
{scopeFor && sync.connectionFor(scopeFor) && (
<DocSyncScopeModal
connection={sync.connectionFor(scopeFor)!}
providerName={sync.providers.find(p => p.id === scopeFor)?.name ?? scopeFor}
suggestedName={slugFor(tripTitle, tripId)}
sync={sync}
onClose={() => setScopeFor(null)}
onBound={() => setScopeFor(null)}
/>
)}
</>
)
}
const ATTENTION_STATES = ['conflict', 'remote_missing', 'rejected_type', 'too_large', 'error'] as const
/** The stores: the ones this trip uses, then the ones it could. */
function Sidebar({
links,
providers,
available,
activeId,
isOwner,
onSelect,
onAdd,
}: {
links: DocSyncLink[]
providers: DocSyncProvider[]
available: DocSyncProvider[]
activeId: number | null
isOwner: boolean
onSelect: (id: number) => void
onAdd: (p: DocSyncProvider) => void
}) {
const { t } = useTranslation()
return (
<nav className="space-y-5">
{links.length > 0 && (
<div>
<h4 className="mb-2 px-1 text-caption font-medium text-content-muted">
{t('docsync.sidebar.connected')}
</h4>
<ul className="space-y-1.5">
{links.map(link => (
<li key={link.id}>
<StoreButton
providerId={link.providerId}
title={providers.find(p => p.id === link.providerId)?.name || link.providerId}
subtitle={link.remoteLabel || link.remoteRootPath || link.scopeKey}
state={link.lastSyncState}
active={link.id === activeId}
onClick={() => onSelect(link.id)}
/>
</li>
))}
</ul>
</div>
)}
{isOwner && available.length > 0 && (
<div>
<h4 className="mb-2 px-1 text-caption font-medium text-content-muted">
{links.length === 0 ? t('docsync.addProvider') : t('docsync.addAnother')}
</h4>
<ul className="space-y-1.5">
{available.map(p => (
<li key={p.id}>
<StoreButton
providerId={p.id}
title={p.name}
subtitle={t(`docsync.model.${p.id}`)}
onClick={() => onAdd(p)}
addable
/>
</li>
))}
</ul>
</div>
)}
</nav>
)
}
/**
* One row in the store list.
*
* The selected row is marked with the accent on a subtle fill rather than a
* heavy border, so a list of five does not turn into five competing boxes.
*/
function StoreButton({
providerId,
title,
subtitle,
state,
active,
addable,
onClick,
}: {
providerId: string
title: string
subtitle: string
state?: string
active?: boolean
addable?: boolean
onClick: () => void
}) {
const Icon = DOCUMENT_PROVIDER_ICONS[providerId]
return (
<button
type="button"
onClick={onClick}
aria-current={active ? 'true' : undefined}
className={[
'group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-colors',
active
? 'border-transparent bg-accent-subtle'
: 'border-edge bg-surface hover:bg-surface-hover',
].join(' ')}
>
<span
className={[
'grid h-8 w-8 shrink-0 place-items-center rounded-lg',
active ? 'bg-surface' : 'bg-surface-secondary',
].join(' ')}
>
{Icon ? <Icon className={`h-4 w-4 ${active ? 'text-accent-on' : 'text-content'}`} /> : null}
</span>
<span className="min-w-0 flex-1">
<span className={`block truncate text-body ${active ? 'font-medium text-accent-on' : 'text-content'}`}>
{title}
</span>
<span className="mt-0.5 block truncate text-caption text-content-muted">{subtitle}</span>
</span>
{addable ? (
<Plus size={14} className="shrink-0 text-content-faint transition-transform group-hover:scale-110" />
) : state ? (
<StateBadge state={state} compact />
) : null}
</button>
)
}
/** What a person still has to decide about, named rather than counted in a chip. */
function Attention({ counts }: { counts: Record<string, number> }) {
const { t } = useTranslation()
const rows = ATTENTION_STATES.filter(k => (counts[k] ?? 0) > 0)
return (
<section className="rounded-xl border border-edge bg-surface">
<h4 className="flex items-center gap-2 border-b border-edge-faint px-4 py-2.5 text-body font-medium text-content">
<AlertCircle size={14} className="text-warning" />
{t('docsync.issues.title')}
</h4>
<ul className="divide-y divide-edge-faint">
{rows.map(k => (
<li key={k} className="flex items-baseline justify-between gap-3 px-4 py-2.5">
<span className="min-w-0">
<span className="block text-body text-content">{t(`docsync.state.${k}`)}</span>
<span className="mt-0.5 block text-caption text-content-muted">{t(`docsync.issues.${k}`)}</span>
</span>
<span className="shrink-0 text-body font-medium tabular-nums text-content">{counts[k]}</span>
</li>
))}
</ul>
</section>
)
}
/** The detail column before anything is bound: an invitation, not a blank. */
function NothingBound({ isOwner }: { isOwner: boolean }) {
const { t } = useTranslation()
return (
<div className="grid h-full min-h-[16rem] place-items-center rounded-xl border border-dashed border-edge px-6 py-12 text-center">
<div>
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl border border-edge bg-surface-secondary">
<ArrowRight size={18} className="text-content-faint" />
</span>
<p className="mt-3 text-body text-content">{t('docsync.empty.title')}</p>
<p className="mx-auto mt-1 max-w-xs text-caption text-content-muted">
{isOwner ? t('docsync.empty.hintOwner') : t('docsync.empty.hintMember')}
</p>
</div>
</div>
)
}
function Empty() {
const { t } = useTranslation()
return (
<div className="rounded-xl border border-dashed border-edge px-6 py-12 text-center">
<span className="mx-auto grid h-11 w-11 place-items-center rounded-xl border border-edge bg-surface-secondary">
<Check size={18} className="text-content-faint" />
</span>
<p className="mt-3 text-body text-content">{t('docsync.noProviders')}</p>
<p className="mx-auto mt-1 max-w-sm text-caption text-content-muted">{t('docsync.noProvidersHint')}</p>
</div>
)
}
/**
* A folder name from the trip's own title, so nobody has to invent one.
*
* The id is appended because two trips can share a title and a folder cannot.
*/
function slugFor(title: string | undefined, tripId: number | string): string {
const base = (title || 'trek')
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
return `${base || 'trek'}-${tripId}`
}
@@ -0,0 +1,367 @@
// FE-DOCSYNC-SCOPE-001 to FE-DOCSYNC-SCOPE-022
/**
* The folder picker of the document-sync dialog.
*
* What matters to somebody opening it: the folders of their own store show up,
* the one they pick is the one that gets bound, making a new folder is one
* action rather than two, and a store that refuses to answer says so instead of
* pretending the archive is empty.
*
* The component takes the `useDocSync` hook as a prop, so the double here is the
* real hook with `api/client` mocked underneath — the assertions are then about
* the requests that actually leave, not about a hand-written stand-in. The hook
* is created with `enabled: false` because the panel-level lists it would fetch
* belong to the dialog around this modal, not to the picker.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import en from '@trek/shared/i18n/en'
import { render, screen, fireEvent, waitFor } from '../../../../tests/helpers/render'
import type { DocSyncConnection, DocSyncScope } from './useDocSync'
const S = en as unknown as Record<string, string>
const listScopes = vi.fn()
const createScope = vi.fn()
const createLink = vi.fn()
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: () => Promise.resolve([]),
status: () => Promise.resolve({ items: {}, links: [] }),
listConnections: () => Promise.resolve([]),
listLinks: () => Promise.resolve([]),
listScopes: (tripId: number | string, connectionId: number, q?: string) => listScopes(tripId, connectionId, q),
createScope: (tripId: number | string, connectionId: number, name: string) => createScope(tripId, connectionId, name),
createLink: (tripId: number | string, data: unknown) => createLink(tripId, data),
},
}
})
import DocSyncScopeModal from './DocSyncScopeModal'
import { useDocSync } from './useDocSync'
const TRIP_ID = 42
const connection: DocSyncConnection = {
id: 5,
providerId: 'paperless',
baseUrl: 'https://docs.example.org',
settings: {},
secrets: {},
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
}
const folder = (n: number, label: string, remoteRootPath: string | null = `/Documents/${label}`): DocSyncScope =>
({ scopeKey: `folder-${n}`, label, remoteRootId: String(n), remoteRootPath })
/** Seven, so the list is one over the threshold that earns a search box. */
const SEVEN = [
folder(1, 'Trips'),
folder(2, 'Archive'),
folder(3, 'Receipts'),
folder(4, 'Invoices'),
folder(5, 'Photos'),
folder(6, 'Manuals'),
folder(7, 'Warranty'),
]
const SIX = SEVEN.slice(0, 6)
const THREE = SEVEN.slice(0, 3)
const onClose = vi.fn()
const onBound = vi.fn()
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
function Harness({ suggestedName = 'trek-paris-42', providerName = 'Paperless' }: {
suggestedName?: string
providerName?: string
}) {
const sync = useDocSync(TRIP_ID, false)
return (
<DocSyncScopeModal
connection={connection}
providerName={providerName}
suggestedName={suggestedName}
sync={sync}
onClose={onClose}
onBound={onBound}
/>
)
}
const nameField = () => screen.getByPlaceholderText(S['docsync.newFolderPlaceholder'])
const createButton = () => screen.getByRole('button', { name: S['docsync.scope.createAction'] })
const searchField = () => screen.queryByPlaceholderText(S['docsync.scope.search'])
const rowFor = (label: string) => screen.getByText(label).closest('button') as HTMLButtonElement
beforeEach(() => {
vi.clearAllMocks()
listScopes.mockResolvedValue({ scopes: THREE })
createScope.mockResolvedValue(folder(9, 'Paris 2026'))
createLink.mockResolvedValue({ id: 1 })
})
describe('DocSyncScopeModal list', () => {
it('FE-DOCSYNC-SCOPE-001: asks the store for its folders as soon as it opens', async () => {
render(<Harness />)
await waitFor(() => expect(listScopes).toHaveBeenCalledWith(TRIP_ID, 5, undefined))
})
it('FE-DOCSYNC-SCOPE-002: shows every folder the store answered with, and where each one sits', async () => {
listScopes.mockResolvedValue({ scopes: THREE })
render(<Harness />)
expect(await screen.findByText('Trips')).toBeInTheDocument()
expect(screen.getByText('Archive')).toBeInTheDocument()
expect(screen.getByText('Receipts')).toBeInTheDocument()
expect(screen.getByText('/Documents/Archive')).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-003: says nothing about an empty archive while the list is still in flight', async () => {
const pending = deferred<{ scopes: DocSyncScope[] }>()
listScopes.mockReturnValue(pending.promise)
render(<Harness />)
// The dangerous middle state: "no folders" is a claim about the store, and
// it must not be made before the store has answered.
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
pending.resolve({ scopes: THREE })
expect(await screen.findByText('Trips')).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-004: a store with no folders at all says so', async () => {
listScopes.mockResolvedValue({ scopes: [] })
render(<Harness />)
expect(await screen.findByText(S['docsync.noFolders'])).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-005: names the store in the question it asks', async () => {
render(<Harness providerName="Paperless" />)
expect(await screen.findByText(S['docsync.scope.title'].replace('{provider}', 'Paperless'))).toBeInTheDocument()
expect(screen.getByText(S['docsync.scope.intro'])).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-006: the header close button leaves without binding anything', async () => {
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(screen.getAllByRole('button')[0])
expect(onClose).toHaveBeenCalled()
expect(createLink).not.toHaveBeenCalled()
})
})
describe('DocSyncScopeModal search', () => {
it('FE-DOCSYNC-SCOPE-007: a list short enough to read gets no search box', async () => {
listScopes.mockResolvedValue({ scopes: SIX })
render(<Harness />)
await screen.findByText('Trips')
expect(searchField()).not.toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-008: one folder past the threshold the search box appears', async () => {
listScopes.mockResolvedValue({ scopes: SEVEN })
render(<Harness />)
await screen.findByText('Warranty')
expect(searchField()).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-009: typing narrows the list without going back to the store', async () => {
listScopes.mockResolvedValue({ scopes: SEVEN })
render(<Harness />)
await screen.findByText('Warranty')
const before = listScopes.mock.calls.length
fireEvent.change(searchField()!, { target: { value: ' ARCH ' } })
expect(screen.getByText('Archive')).toBeInTheDocument()
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
// Matching is case- and whitespace-insensitive and costs no round trip.
expect(listScopes.mock.calls.length).toBe(before)
})
it('FE-DOCSYNC-SCOPE-010: a query nothing matches says so, and does not claim the store is empty', async () => {
listScopes.mockResolvedValue({ scopes: SEVEN })
render(<Harness />)
await screen.findByText('Warranty')
fireEvent.change(searchField()!, { target: { value: 'zzz' } })
expect(screen.getByText(S['docsync.scope.noMatch'])).toBeInTheDocument()
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
})
})
describe('DocSyncScopeModal picking an existing folder', () => {
it('FE-DOCSYNC-SCOPE-011: binds the folder that was clicked, with the defaults a new binding gets', async () => {
render(<Harness />)
await screen.findByText('Archive')
fireEvent.click(rowFor('Archive'))
await waitFor(() => expect(createLink).toHaveBeenCalledWith(TRIP_ID, {
connectionId: 5,
scopeKey: 'folder-2',
remoteRootId: '2',
remoteRootPath: '/Documents/Archive',
remoteLabel: 'Archive',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
}))
})
it('FE-DOCSYNC-SCOPE-012: a bound folder closes the picker', async () => {
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(rowFor('Trips'))
await waitFor(() => expect(onBound).toHaveBeenCalled())
})
it('FE-DOCSYNC-SCOPE-013: a refused binding leaves the picker open so another folder can be tried', async () => {
createLink.mockRejectedValueOnce(Object.assign(new Error('nope'), {
response: { data: { error: 'forbidden' } },
}))
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(rowFor('Trips'))
await waitFor(() => expect(createLink).toHaveBeenCalled())
expect(onBound).not.toHaveBeenCalled()
expect(screen.getByText('Trips')).toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-014: while one folder is being bound nothing else can be started', async () => {
const pending = deferred<{ id: number }>()
createLink.mockReturnValueOnce(pending.promise)
render(<Harness />)
await screen.findByText('Trips')
fireEvent.click(rowFor('Trips'))
expect(rowFor('Archive')).toBeDisabled()
expect(createButton()).toBeDisabled()
pending.resolve({ id: 1 })
await waitFor(() => expect(onBound).toHaveBeenCalled())
})
})
describe('DocSyncScopeModal creating a folder', () => {
it('FE-DOCSYNC-SCOPE-015: the trip name is already in the field, so creating is one click', async () => {
render(<Harness suggestedName="trek-paris-42" />)
await screen.findByText('Trips')
expect(nameField()).toHaveValue('trek-paris-42')
expect(createButton()).toBeEnabled()
})
it('FE-DOCSYNC-SCOPE-016: creating makes the folder and binds it in one go', async () => {
createScope.mockResolvedValue(folder(9, 'Paris 2026', '/Documents/Paris 2026'))
render(<Harness suggestedName="Paris 2026" />)
await screen.findByText('Trips')
fireEvent.click(createButton())
await waitFor(() => expect(createScope).toHaveBeenCalledWith(TRIP_ID, 5, 'Paris 2026'))
await waitFor(() => expect(createLink).toHaveBeenCalledWith(TRIP_ID, expect.objectContaining({
connectionId: 5,
scopeKey: 'folder-9',
remoteRootId: '9',
remoteLabel: 'Paris 2026',
})))
await waitFor(() => expect(onBound).toHaveBeenCalled())
})
it('FE-DOCSYNC-SCOPE-021: a refused create says why instead of doing nothing', async () => {
// The create had no failure path at all: the rejection escaped the click
// handler unhandled, nothing was bound, and the dialog said nothing — the
// button blinked and the person was left guessing. Every other write in the
// hook reports, and so does this one now.
createScope.mockRejectedValue({ response: { data: { error: 'quota_exceeded' } } })
render(<Harness suggestedName="Paris 2026" />)
await screen.findByText('Trips')
fireEvent.click(createButton())
await waitFor(() => expect(screen.getByText(S['docsync.error.quota_exceeded'])).toBeInTheDocument())
expect(createLink).not.toHaveBeenCalled()
expect(onBound).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-SCOPE-022: the create button comes back after a refusal', async () => {
createScope.mockRejectedValue({ response: { data: { error: 'quota_exceeded' } } })
render(<Harness suggestedName="Paris 2026" />)
await screen.findByText('Trips')
fireEvent.click(createButton())
await waitFor(() => expect(createButton()).toBeEnabled())
})
it('FE-DOCSYNC-SCOPE-017: a blank or whitespace-only name creates nothing', async () => {
render(<Harness suggestedName="" />)
await screen.findByText('Trips')
expect(createButton()).toBeDisabled()
fireEvent.change(nameField(), { target: { value: ' ' } })
expect(createButton()).toBeDisabled()
fireEvent.keyDown(nameField(), { key: 'Enter' })
expect(createScope).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-SCOPE-018: Enter in the name field creates the typed name, not the suggested one', async () => {
createScope.mockResolvedValue(folder(9, 'Belege'))
render(<Harness suggestedName="trek-paris-42" />)
await screen.findByText('Trips')
fireEvent.change(nameField(), { target: { value: ' Belege ' } })
fireEvent.keyDown(nameField(), { key: 'Enter' })
await waitFor(() => expect(createScope).toHaveBeenCalledWith(TRIP_ID, 5, 'Belege'))
})
})
describe('DocSyncScopeModal errors', () => {
it('FE-DOCSYNC-SCOPE-019: a store that refuses the credentials says so instead of showing an empty archive', async () => {
listScopes.mockResolvedValue({ scopes: [], error: 'unauthorized' })
render(<Harness />)
expect(await screen.findByText(S['docsync.error.unauthorized'])).toBeInTheDocument()
expect(screen.queryByText(S['docsync.noFolders'])).not.toBeInTheDocument()
})
it('FE-DOCSYNC-SCOPE-020: an error wins over folders the store still managed to list', async () => {
listScopes.mockResolvedValue({ scopes: THREE, error: 'scope_missing' })
render(<Harness />)
expect(await screen.findByText(S['docsync.error.scope_missing'])).toBeInTheDocument()
expect(screen.queryByText('Trips')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,175 @@
import { useEffect, useMemo, useState } from 'react'
import { FolderPlus, Loader2, Search } from 'lucide-react'
import Modal from '../../shared/Modal'
import { useTranslation } from '../../../i18n/TranslationContext'
import type { DocSyncConnection, DocSyncScope, useDocSync } from './useDocSync'
/**
* Picking the container a trip lives in.
*
* The list is filtered client-side: these are a person's own folders or tags,
* so there are tens of them, not thousands, and a round trip per keystroke
* would be slower than the filter it replaces.
*
* "Create" is a first-class action rather than a fallback at the bottom,
* because for a new trip it is the common case — there is no folder yet.
*/
export default function DocSyncScopeModal({
connection,
providerName,
suggestedName,
sync,
onClose,
onBound,
}: {
connection: DocSyncConnection
providerName: string
/** Pre-filled name for a new container, from the trip's title. */
suggestedName: string
sync: ReturnType<typeof useDocSync>
onClose: () => void
onBound: () => void
}) {
const { t } = useTranslation()
const [scopes, setScopes] = useState<DocSyncScope[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [newName, setNewName] = useState(suggestedName)
const [working, setWorking] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
void (async () => {
const res = await sync.loadScopes(connection.id)
if (cancelled) return
setScopes(res.scopes)
setError(res.error ?? null)
})()
return () => { cancelled = true }
}, [connection.id, sync])
const shown = useMemo(() => {
const needle = query.trim().toLowerCase()
if (!needle || !scopes) return scopes ?? []
return scopes.filter(s => s.label.toLowerCase().includes(needle))
}, [scopes, query])
const bind = async (scope: DocSyncScope) => {
setWorking(scope.scopeKey)
const ok = await sync.createLink({
connectionId: connection.id,
scopeKey: scope.scopeKey,
remoteRootId: scope.remoteRootId,
remoteRootPath: scope.remoteRootPath,
remoteLabel: scope.label,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
})
setWorking(null)
if (ok) onBound()
}
const createAndBind = async () => {
const name = newName.trim()
if (!name) return
setWorking('__new__')
try {
// A refused create leaves nothing to bind; the hook has already put the
// reason where the dialog shows it.
const scope = await sync.createScope(connection.id, name)
if (scope) await bind(scope)
} finally {
setWorking(null)
}
}
return (
<Modal isOpen onClose={onClose} size="md" title={t('docsync.scope.title', { provider: providerName })}>
<div className="space-y-4">
<p className="text-caption text-content-muted">{t('docsync.scope.intro')}</p>
<div className="rounded-xl border border-edge bg-surface-secondary p-3">
<span className="mb-2 block text-body font-medium text-content">{t('docsync.scope.createTitle')}</span>
<div className="flex gap-2">
<input
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void createAndBind() }}
placeholder={t('docsync.newFolderPlaceholder')}
className="min-w-0 flex-1 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content ring-accent focus:outline-none focus:ring-2"
/>
<button
type="button"
onClick={() => void createAndBind()}
disabled={!newName.trim() || working !== null}
className="flex shrink-0 items-center gap-2 rounded-lg bg-accent px-3.5 py-2 text-body font-medium text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
>
{working === '__new__' ? <Loader2 size={14} className="animate-spin" /> : <FolderPlus size={14} />}
{t('docsync.scope.createAction')}
</button>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-body font-medium text-content">{t('docsync.scope.pickTitle')}</span>
{scopes && scopes.length > 6 && (
<span className="relative">
<Search size={13} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('docsync.scope.search')}
className="w-44 rounded-lg border border-edge bg-surface-input py-1.5 pl-8 pr-2.5 text-caption text-content ring-accent focus:outline-none focus:ring-2"
/>
</span>
)}
</div>
{sync.error && (
<p className="mb-2 rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
{t(`docsync.error.${sync.error}`)}
</p>
)}
{scopes === null ? (
<div className="grid place-items-center rounded-xl border border-edge py-8">
<Loader2 size={18} className="animate-spin text-content-faint" />
</div>
) : error ? (
<p className="rounded-xl border border-edge bg-danger-soft px-3 py-2.5 text-caption text-danger">
{t(`docsync.error.${error}`)}
</p>
) : shown.length === 0 ? (
<p className="rounded-xl border border-dashed border-edge px-3 py-6 text-center text-caption text-content-muted">
{query ? t('docsync.scope.noMatch') : t('docsync.noFolders')}
</p>
) : (
<ul className="max-h-64 divide-y divide-edge-faint overflow-y-auto rounded-xl border border-edge">
{shown.map(s => (
<li key={s.scopeKey}>
<button
type="button"
onClick={() => void bind(s)}
disabled={working !== null}
className="flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition-colors hover:bg-surface-hover disabled:opacity-50"
>
<span className="min-w-0">
<span className="block truncate text-body text-content">{s.label}</span>
{s.remoteRootPath && (
<span className="block truncate text-caption text-content-faint">{s.remoteRootPath}</span>
)}
</span>
{working === s.scopeKey && <Loader2 size={14} className="shrink-0 animate-spin text-content-faint" />}
</button>
</li>
))}
</ul>
)}
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,389 @@
// FE-DOCSYNC-HOOK-001 to FE-DOCSYNC-HOOK-022
/**
* The document-sync hook.
*
* Everything the panel and the phone sheet know about sync comes through here,
* so the interesting behaviour is what a person sees: holdings that arrive on a
* different route than the bindings they belong to, a dialog that must not flash
* white every time a switch is flipped, a toggle that moves before the server
* has answered, and a probe that has to produce a verdict even when the request
* itself fell over.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, act, waitFor } from '../../../../tests/helpers/render'
const providersApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const statusApi = vi.fn(async (_tripId: number | string): Promise<unknown> => ({}))
const listConnectionsApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const listLinksApi = vi.fn(async (_tripId: number | string): Promise<unknown> => [])
const saveConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({}))
const testConnectionApi = vi.fn(async (_tripId: number | string, _data: unknown): Promise<unknown> => ({ connected: true }))
const updateLinkApi = vi.fn(async (_tripId: number | string, _linkId: number, _patch: unknown): Promise<unknown> => ({}))
const syncNowApi = vi.fn(async (_tripId: number | string, _linkId: number, _full: boolean): Promise<unknown> => ({}))
vi.mock('../../../api/client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../api/client')>()
return {
...actual,
docsyncApi: {
...actual.docsyncApi,
providers: (tripId: number | string) => providersApi(tripId),
status: (tripId: number | string) => statusApi(tripId),
listConnections: (tripId: number | string) => listConnectionsApi(tripId),
listLinks: (tripId: number | string) => listLinksApi(tripId),
saveConnection: (tripId: number | string, data: unknown) => saveConnectionApi(tripId, data),
testConnection: (tripId: number | string, data: unknown) => testConnectionApi(tripId, data),
updateLink: (tripId: number | string, linkId: number, patch: unknown) => updateLinkApi(tripId, linkId, patch),
syncNow: (tripId: number | string, linkId: number, full: boolean) => syncNowApi(tripId, linkId, full),
},
}
})
import { useDocSync, canManageDocSync } from './useDocSync'
const TRIP = 3
const provider = (overrides: Record<string, unknown> = {}) => ({
id: 'nextcloud',
name: 'Nextcloud',
description: null,
icon: 'cloud',
available: true,
fields: [],
...overrides,
})
const connection = (overrides: Record<string, unknown> = {}) => ({
id: 9,
providerId: 'nextcloud',
baseUrl: 'https://cloud.example',
settings: {},
secrets: {},
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
...overrides,
})
const link = (overrides: Record<string, unknown> = {}) => ({
id: 1,
connectionId: 9,
providerId: 'nextcloud',
scopeKey: 'trip',
remoteLabel: 'Trip documents',
remoteRootPath: '/Trips/Rome',
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'newer',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'idle',
lastSyncError: null,
webhookUrl: null,
...overrides,
})
/** A promise this test settles by hand, to hold a request open. */
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
return { promise, resolve, reject }
}
/** Mount the hook and record `loading` on every render it goes through. */
function mount(tripId: number | string = TRIP, enabled = true) {
const seenLoading: boolean[] = []
const view = renderHook(() => {
const hook = useDocSync(tripId, enabled)
seenLoading.push(hook.loading)
return hook
})
return { ...view, seenLoading }
}
/** Mount and wait until the first load is through. */
async function mountLoaded(tripId: number | string = TRIP) {
const view = mount(tripId)
await waitFor(() => expect(view.result.current.loading).toBe(false))
return view
}
beforeEach(() => {
vi.clearAllMocks()
providersApi.mockResolvedValue([provider()])
statusApi.mockResolvedValue({ items: {}, links: [] })
listConnectionsApi.mockResolvedValue([connection()])
listLinksApi.mockResolvedValue([link()])
saveConnectionApi.mockResolvedValue({})
testConnectionApi.mockResolvedValue({ connected: true })
updateLinkApi.mockResolvedValue({})
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
})
describe('useDocSync initial load', () => {
it('FE-DOCSYNC-HOOK-001: joins the status route holdings onto the binding they belong to', async () => {
const holdings = { inTrek: 7, atProvider: 5, paired: 4, missing: 1 }
listLinksApi.mockResolvedValue([link()])
statusApi.mockResolvedValue({ items: { conflict: 2, pending: 1 }, links: [{ id: 1, holdings }] })
const { result } = await mountLoaded()
expect(result.current.links).toHaveLength(1)
expect(result.current.links[0].holdings).toEqual(holdings)
expect(result.current.itemCounts).toEqual({ conflict: 2, pending: 1 })
expect(result.current.connections).toEqual([connection()])
})
it('FE-DOCSYNC-HOOK-002: a binding the status route never mentions does not inherit another binding counts', async () => {
const holdings = { inTrek: 7, atProvider: 5, paired: 4, missing: 1 }
listLinksApi.mockResolvedValue([link(), link({ id: 2, remoteLabel: 'Receipts' })])
statusApi.mockResolvedValue({ items: {}, links: [{ id: 1, holdings }] })
const { result } = await mountLoaded()
expect(result.current.links[0].holdings).toEqual(holdings)
expect(result.current.links[1].holdings).toBeUndefined()
})
it('FE-DOCSYNC-HOOK-003: a status body without an items map leaves the counts empty', async () => {
statusApi.mockResolvedValue({})
const { result } = await mountLoaded()
expect(result.current.itemCounts).toEqual({})
expect(result.current.links[0].holdings).toBeUndefined()
})
it('FE-DOCSYNC-HOOK-004: offers only the providers this instance can actually use', async () => {
providersApi.mockResolvedValue([
provider(),
provider({ id: 'gdrive', name: 'Google Drive', available: false }),
])
const { result } = await mountLoaded()
expect(result.current.providers.map(p => p.id)).toEqual(['nextcloud'])
})
it('FE-DOCSYNC-HOOK-005: a failed load leaves nothing to configure instead of blowing up', async () => {
// The addon was switched off, or this member lost access to the trip.
providersApi.mockRejectedValue(Object.assign(new Error('forbidden'), { response: { status: 403 } }))
const { result } = await mountLoaded()
expect(result.current.providers).toEqual([])
expect(result.current.links).toEqual([])
expect(result.current.error).toBeNull()
expect(result.current.loading).toBe(false)
})
it('FE-DOCSYNC-HOOK-006: asks the server nothing while the panel is closed', async () => {
const { result } = mount(TRIP, false)
await act(async () => { await Promise.resolve() })
expect(providersApi).not.toHaveBeenCalled()
expect(listLinksApi).not.toHaveBeenCalled()
expect(result.current.loading).toBe(false)
})
})
describe('useDocSync spinner', () => {
it('FE-DOCSYNC-HOOK-007: shows the spinner for the first load, which has nothing to show yet', async () => {
const gate = deferred<unknown>()
listLinksApi.mockReturnValueOnce(gate.promise)
const { result, seenLoading } = mount()
expect(result.current.loading).toBe(true)
expect(seenLoading[seenLoading.length - 1]).toBe(true)
await act(async () => { gate.resolve([link()]) })
await waitFor(() => expect(result.current.loading).toBe(false))
})
it('FE-DOCSYNC-HOOK-008: never raises the spinner again while a mutation refetches', async () => {
// Regression guard: a refetch that flips `loading` swaps the whole dialog
// for a spinner and back, so flipping one switch flashes the panel white.
const { result, seenLoading } = await mountLoaded()
const gate = deferred<unknown>()
listLinksApi.mockReturnValueOnce(gate.promise)
seenLoading.length = 0
let pending!: Promise<void>
await act(async () => { pending = result.current.updateLink(1, { syncEnabled: false }) })
expect(result.current.loading).toBe(false)
await act(async () => {
gate.resolve([link({ syncEnabled: false })])
await pending
})
// the refetch really happened...
expect(listLinksApi).toHaveBeenCalledTimes(2)
// ...and the panel never went blank for it
expect(seenLoading).not.toContain(true)
expect(result.current.loading).toBe(false)
})
})
describe('useDocSync updateLink', () => {
it('FE-DOCSYNC-HOOK-009: moves the switch before the server has answered', async () => {
const { result } = await mountLoaded()
const gate = deferred<unknown>()
updateLinkApi.mockReturnValueOnce(gate.promise)
let pending!: Promise<void>
await act(async () => { pending = result.current.updateLink(1, { direction: 'pull' }) })
expect(result.current.links[0].direction).toBe('pull')
expect(updateLinkApi).toHaveBeenCalledWith(TRIP, 1, { direction: 'pull' })
await act(async () => { gate.resolve({}); await pending })
})
it('FE-DOCSYNC-HOOK-010: reloads afterwards, so the server row is what stays on screen', async () => {
const { result } = await mountLoaded()
listLinksApi.mockResolvedValue([link({ direction: 'pull', lastSyncState: 'ok', remoteLabel: 'Trip documents (moved)' })])
await act(async () => { await result.current.updateLink(1, { direction: 'pull' }) })
expect(listLinksApi).toHaveBeenCalledTimes(2)
expect(result.current.links[0].remoteLabel).toBe('Trip documents (moved)')
expect(result.current.links[0].lastSyncState).toBe('ok')
})
it('FE-DOCSYNC-HOOK-011: a rejected update does not leave the optimistic value standing', async () => {
const { result } = await mountLoaded()
updateLinkApi.mockRejectedValueOnce(new Error('conflict'))
await act(async () => {
await expect(result.current.updateLink(1, { syncEnabled: false })).rejects.toThrow('conflict')
})
expect(listLinksApi).toHaveBeenCalledTimes(2)
expect(result.current.links[0].syncEnabled).toBe(true)
})
})
describe('useDocSync testConnection', () => {
it('FE-DOCSYNC-HOOK-012: hands the probe verdict back to the form', async () => {
testConnectionApi.mockResolvedValue({ connected: true, account: 'owner@cloud.example' })
const { result } = await mountLoaded()
let verdict: { connected: boolean; account?: string; error?: string } | undefined
await act(async () => {
verdict = await result.current.testConnection('nextcloud', 'https://cloud.example', { token: 'abc' }, false)
})
expect(verdict).toEqual({ connected: true, account: 'owner@cloud.example' })
expect(testConnectionApi).toHaveBeenCalledWith(TRIP, {
providerId: 'nextcloud',
baseUrl: 'https://cloud.example',
credentials: { token: 'abc' },
allowInsecureTls: false,
})
})
it('FE-DOCSYNC-HOOK-013: a request that throws still produces a verdict the form can render', async () => {
testConnectionApi.mockRejectedValueOnce({ response: { data: { error: 'docsync.unreachable' } } })
const { result } = await mountLoaded()
let verdict: { connected: boolean; error?: string } | undefined
await act(async () => {
verdict = await result.current.testConnection('nextcloud', 'https://typo.example', {}, false)
})
expect(verdict).toEqual({ connected: false, error: 'docsync.unreachable' })
})
it('FE-DOCSYNC-HOOK-014: an error with no message of its own falls back to a generic one', async () => {
testConnectionApi.mockRejectedValueOnce(new Error('socket hang up'))
const { result } = await mountLoaded()
let verdict: { connected: boolean; error?: string } | undefined
await act(async () => {
verdict = await result.current.testConnection('nextcloud', 'https://cloud.example', {}, false)
})
expect(verdict).toEqual({ connected: false, error: 'unknown' })
})
it('FE-DOCSYNC-HOOK-015: marks the probe busy while it runs and clears it when it lands', async () => {
const { result } = await mountLoaded()
const gate = deferred<unknown>()
testConnectionApi.mockReturnValueOnce(gate.promise)
let pending!: Promise<unknown>
await act(async () => { pending = result.current.testConnection('nextcloud', 'https://cloud.example', {}, false) })
expect(result.current.busy).toBe('test')
await act(async () => { gate.resolve({ connected: true }); await pending })
expect(result.current.busy).toBeNull()
})
})
describe('useDocSync run and save feedback', () => {
it('FE-DOCSYNC-HOOK-016: remembers what the last run moved, per binding', async () => {
syncNowApi.mockResolvedValue({ state: 'ok', pulled: 4, pushed: 2, conflicts: 0, missing: 0 })
const { result } = await mountLoaded()
await act(async () => { await result.current.syncNow(1) })
expect(syncNowApi).toHaveBeenCalledWith(TRIP, 1, false)
expect(result.current.lastRunFor(1)).toEqual({ pulled: 4, pushed: 2 })
// a binding nobody ran in this session reports zeros, not someone else numbers
expect(result.current.lastRunFor(2)).toEqual({ pulled: 0, pushed: 0 })
})
it('FE-DOCSYNC-HOOK-017: a run that reports no numbers counts as nothing moved', async () => {
syncNowApi.mockResolvedValue({ state: 'ok' })
const { result } = await mountLoaded()
await act(async () => { await result.current.syncNow(1, true) })
expect(syncNowApi).toHaveBeenCalledWith(TRIP, 1, true)
expect(result.current.lastRunFor(1)).toEqual({ pulled: 0, pushed: 0 })
})
it('FE-DOCSYNC-HOOK-018: a refused save reports failure and surfaces the server reason', async () => {
saveConnectionApi.mockRejectedValueOnce({ response: { data: { message: 'docsync.badCredentials' } } })
const { result } = await mountLoaded()
let ok: boolean | undefined
await act(async () => {
ok = await result.current.saveConnection('nextcloud', 'https://cloud.example', { token: 'nope' }, false)
})
expect(ok).toBe(false)
expect(result.current.error).toBe('docsync.badCredentials')
expect(result.current.busy).toBeNull()
// nothing was refetched, because nothing changed server-side
expect(listLinksApi).toHaveBeenCalledTimes(1)
})
})
describe('canManageDocSync', () => {
it('FE-DOCSYNC-HOOK-019: the trip owner may repoint a binding', () => {
expect(canManageDocSync({ id: 4, role: 'user' }, { user_id: 4 })).toBe(true)
})
it('FE-DOCSYNC-HOOK-020: an instance admin may, even on a trip that is not theirs', () => {
expect(canManageDocSync({ id: 12, role: 'admin' }, { user_id: 4 })).toBe(true)
})
it('FE-DOCSYNC-HOOK-021: an ordinary member may not point the owner archive somewhere else', () => {
expect(canManageDocSync({ id: 12, role: 'user' }, { user_id: 4 })).toBe(false)
expect(canManageDocSync({ id: 12, role: 'user' }, null)).toBe(false)
expect(canManageDocSync({ id: 12, role: 'user' }, {})).toBe(false)
})
it('FE-DOCSYNC-HOOK-022: nobody signed in, nobody may manage', () => {
expect(canManageDocSync(null, { user_id: 4 })).toBe(false)
expect(canManageDocSync(undefined, { user_id: 4 })).toBe(false)
// ids arrive as strings on some routes and as numbers on others
expect(canManageDocSync({ id: '4', role: 'user' }, { user_id: 4 })).toBe(true)
})
})
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { docsyncApi } from '../../../api/client'
import { useServerPing } from '../../../sync/useServerPing'
/**
* All document-sync state and actions, in one hook.
@@ -63,6 +64,27 @@ export interface DocSyncLink {
lastSyncState: string
lastSyncError: string | null
webhookUrl: string | null
/** Standing counts per side, from the status route. */
holdings?: { inTrek: number; atProvider: number; paired: number; missing: number }
}
/**
* Whether this person may change a sync binding.
*
* Only the trip owner: the credential a binding 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.
*
* Shared by both shells rather than written out twice — the desktop file
* manager and the phone sheet must not be able to drift on who may do this.
*/
export function canManageDocSync(
user: { id?: number | string; role?: string } | null | undefined,
trip: { user_id?: number | string } | null | undefined,
): boolean {
if (!user) return false
return user.role === 'admin' || Number(trip?.user_id) === Number(user.id)
}
export function useDocSync(tripId: number | string, enabled: boolean) {
@@ -70,13 +92,28 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
const [connections, setConnections] = useState<DocSyncConnection[]>([])
const [links, setLinks] = useState<DocSyncLink[]>([])
const [itemCounts, setItemCounts] = useState<Record<string, number>>({})
/**
* What the last run of each binding moved, so the flow bar can show numbers
* on its lanes. Kept in memory rather than persisted: it describes one run,
* and after a reload "nothing moved since you got here" is the honest answer.
*/
const [lastRun, setLastRun] = useState<Record<number, { pulled: number; pushed: number }>>({})
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
/**
* Whether the first load is through.
*
* Every mutation refreshes, and a refresh that flips `loading` swaps the whole
* dialog for a spinner and back — a white flash on something as small as
* toggling a direction. Only the first load has nothing to show yet.
*/
const loadedOnce = useRef(false)
const load = useCallback(async () => {
if (!enabled) return
setLoading(true)
if (!loadedOnce.current) setLoading(true)
try {
const [p, c, l, s] = await Promise.all([
docsyncApi.providers(tripId),
@@ -86,9 +123,14 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
])
setProviders(p as DocSyncProvider[])
setConnections(c as DocSyncConnection[])
setLinks(l as DocSyncLink[])
setItemCounts(((s as { items?: Record<string, number> }).items) || {})
// The status route carries the holdings; the links route does not, so the
// two are merged here rather than asking every caller to join them.
const status = s as { items?: Record<string, number>; links?: DocSyncLink[] }
const holdingsById = new Map((status.links ?? []).map(x => [x.id, x.holdings]))
setLinks((l as DocSyncLink[]).map(x => ({ ...x, holdings: holdingsById.get(x.id) })))
setItemCounts(status.items || {})
setError(null)
loadedOnce.current = true
} 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.
@@ -100,6 +142,12 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
useEffect(() => { void load() }, [load])
// A run started by another member moves the same documents and the same
// counts. The event is a ping rather than a payload: the numbers it would
// carry are per-binding server state, and reading them back is the only way
// this panel and that member's panel end up saying the same thing.
useServerPing('docsync:changed', enabled, load)
const connectionFor = useCallback(
(providerId: string) => connections.find(c => c.providerId === providerId) || null,
[connections],
@@ -143,10 +191,23 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
return res
}, [tripId])
const createScope = useCallback(async (connectionId: number, name: string) => {
/**
* Make a container at the provider.
*
* Reports failure the same way every other write here does — a rejected
* create used to escape as an unhandled rejection, so the button blinked and
* nothing happened and nothing was said. Null rather than a throw, because
* the caller's next step is to bind what came back, and there is nothing to
* bind.
*/
const createScope = useCallback(async (connectionId: number, name: string): Promise<DocSyncScope | null> => {
setBusy('scope')
setError(null)
try {
return await docsyncApi.createScope(tripId, connectionId, name) as DocSyncScope
} catch (e: unknown) {
setError(readError(e))
return null
} finally {
setBusy(null)
}
@@ -189,14 +250,22 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
const syncNow = useCallback(async (linkId: number, full = false) => {
setBusy(`sync-${linkId}`)
try {
const res = await docsyncApi.syncNow(tripId, linkId, full)
const res = await docsyncApi.syncNow(tripId, linkId, full) as {
state: string; pulled: number; pushed: number; conflicts: number; missing: number
}
setLastRun(prev => ({ ...prev, [linkId]: { pulled: res.pulled ?? 0, pushed: res.pushed ?? 0 } }))
await load()
return res as { state: string; pulled: number; pushed: number; conflicts: number; missing: number }
return res
} finally {
setBusy(null)
}
}, [tripId, load])
const lastRunFor = useCallback(
(linkId: number) => lastRun[linkId] ?? { pulled: 0, pushed: 0 },
[lastRun],
)
const resolveConflict = useCallback(async (itemId: number, keep: 'trek' | 'provider' | 'both') => {
await docsyncApi.resolve(tripId, itemId, keep)
await load()
@@ -212,7 +281,7 @@ export function useDocSync(tripId: number | string, enabled: boolean) {
connectionFor, load,
saveConnection, testConnection,
loadScopes, createScope,
createLink, updateLink, removeLink, syncNow, resolveConflict,
createLink, updateLink, removeLink, syncNow, resolveConflict, lastRunFor,
}
}
+2 -10
View File
@@ -7,6 +7,7 @@ import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../ty
import { useCanDo } from '../../store/permissionsStore'
import { useTripStore } from '../../store/tripStore'
import { useAuthStore } from '../../store/authStore'
import { canManageDocSync } from './docsync/useDocSync'
import { getAuthUrl } from '../../api/authUrl'
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
import { openFile as openFileInTab } from '../../utils/fileDownload'
@@ -41,16 +42,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
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 isTripOwner = canManageDocSync(currentUser, trip)
const { t, locale } = useTranslation()
const loadTrash = useCallback(async () => {
@@ -1,94 +1,13 @@
import { useEffect, useRef } from 'react'
import { addListener, removeListener } from '../../api/websocket'
import { isEffectivelyOnline, onNetworkModeChange } from '../../sync/networkMode'
/**
* How long pings are collected before one refetch goes out.
*
* Long enough to fold a burst into a single request — clearing twelve checked
* items issues twelve deletes, and an import writes a whole list — short enough
* that a single edit still reads as instant.
*/
const COALESCE_MS = 250
import { useServerPing } from '../../sync/useServerPing'
/**
* Re-read the bag weights when the server says they moved (#2191).
*
* The ping is content-free by design: totals are summed across every member,
* including people whose items this client may not see, so the event cannot
* carry the new numbers without leaking what produced them. That makes every
* ping a refetch, which is why this coalesces bursts and never lets two
* requests overlap — the server pings the whole room, the originating socket
* included, so a busy trip would otherwise have every client refetching once
* per written item.
*
* A ping sent while this client is disconnected is gone for good, and bags have
* no offline cache to sync back, so the two moments where that can have happened
* count as pings of their own: the room re-join after a reconnect, and the
* return to online.
*
* `reload` may change identity every render; the latest one is always used.
* carry the new numbers without leaking what produced them. `useServerPing`
* carries the coalescing, the in-flight guard and the reconnect handling.
*/
export function useBagTotalsPing(enabled: boolean, reload: () => Promise<void>): void {
const reloadRef = useRef(reload)
reloadRef.current = reload
useEffect(() => {
if (!enabled) return
let timer: ReturnType<typeof setTimeout> | null = null
let inFlight = false
/** A ping that arrived while a refetch was running: serve it once that lands. */
let pendingAfterFlight = false
let cancelled = false
const run = (): void => {
if (cancelled) return
if (inFlight) {
pendingAfterFlight = true
return
}
inFlight = true
void reloadRef.current().finally(() => {
inFlight = false
if (pendingAfterFlight && !cancelled) {
pendingAfterFlight = false
run()
}
})
}
const schedule = (): void => {
if (timer) return // already collecting this burst
timer = setTimeout(() => {
timer = null
run()
}, COALESCE_MS)
}
const handler = (event: Record<string, unknown>) => {
// 'joined' is the room re-join the socket sends on every reconnect, so it
// is also the moment the totals on screen are the ones from before the
// drop: whatever moved them in the meantime pinged into a closed socket.
if (event.type !== 'packing:bag-totals' && event.type !== 'joined') return
schedule()
}
let wasOnline = isEffectivelyOnline()
const onMode = (): void => {
const online = isEffectivelyOnline()
// Only the way back matters: that is when the surfaces stop summing what
// this client can see and go back to trusting the server numbers.
if (online && !wasOnline) schedule()
wasOnline = online
}
addListener(handler)
const unsubscribeMode = onNetworkModeChange(onMode)
return () => {
cancelled = true
removeListener(handler)
unsubscribeMode()
if (timer) clearTimeout(timer)
}
}, [enabled])
useServerPing('packing:bag-totals', enabled, reload)
}
@@ -0,0 +1,156 @@
// FE-DOCSYNC-ICONS-001 to FE-DOCSYNC-ICONS-014
/**
* The document providers' marks, and TREK's own.
*
* These are static files, so the only things worth holding still are the ones a
* person would notice if they slipped: a provider the server seeds but the
* client has no glyph for (the row renders nothing at all), a mark that keeps a
* brand colour and so stays black on a dark background, and the sizing contract
* the callers rely on — `size` for the bare lucide-sized rows, a `className`
* everywhere the surrounding Tailwind box decides how big the glyph is.
*/
import { describe, it, expect } from 'vitest'
import { DOCUMENT_PROVIDER_IDS } from '@trek/shared'
import { render } from '../../../tests/helpers/render'
import { DOCUMENT_PROVIDER_ICONS } from './DocumentProviderIcons'
import TrekMark from './TrekMark'
const marks = Object.entries(DOCUMENT_PROVIDER_ICONS)
/** The svg plus everything drawn inside it. */
function allElements(svg: SVGElement): Element[] {
return [svg, ...Array.from(svg.querySelectorAll('*'))]
}
function renderMark(Icon: (typeof marks)[number][1], props: Record<string, unknown> = {}): SVGElement {
const { container } = render(<Icon {...props} />)
const svg = container.querySelector('svg')
if (!svg) throw new Error('no svg rendered')
return svg as unknown as SVGElement
}
describe('DOCUMENT_PROVIDER_ICONS', () => {
it('FE-DOCSYNC-ICONS-001: carries exactly one mark per provider the server seeds', () => {
// A provider seeded without a glyph here renders an empty slot in the
// connect modal and the addon shelf; a glyph with no provider is dead code.
expect(Object.keys(DOCUMENT_PROVIDER_ICONS).sort()).toEqual([...DOCUMENT_PROVIDER_IDS].sort())
})
it('FE-DOCSYNC-ICONS-002: resolves each id the way the panels look one up', () => {
for (const id of DOCUMENT_PROVIDER_IDS) {
expect(typeof DOCUMENT_PROVIDER_ICONS[id], id).toBe('function')
}
})
it('FE-DOCSYNC-ICONS-003: renders an svg for every provider', () => {
for (const [id, Icon] of marks) {
const { container } = render(<Icon />)
expect(container.querySelector('svg'), id).not.toBeNull()
}
})
it('FE-DOCSYNC-ICONS-004: draws only in currentColor, so the mark inverts with the theme', () => {
const hex = /#[0-9a-f]{3}/i
for (const [id, Icon] of marks) {
const svg = renderMark(Icon)
const painted: string[] = []
for (const el of allElements(svg)) {
for (const attr of Array.from(el.attributes)) {
expect(attr.value, `${id} ${attr.name}`).not.toMatch(hex)
if (attr.name === 'fill' || attr.name === 'stroke') painted.push(attr.value)
}
}
expect(painted.length, id).toBeGreaterThan(0)
for (const value of painted) expect(['currentColor', 'none'], id).toContain(value)
expect(painted, id).toContain('currentColor')
}
})
it('FE-DOCSYNC-ICONS-005: keeps a viewBox, so the glyph scales instead of cropping', () => {
for (const [id, Icon] of marks) {
expect(renderMark(Icon).getAttribute('viewBox'), id).toMatch(/^0 0 \d+ \d+$/)
}
})
it('FE-DOCSYNC-ICONS-006: stays out of the accessibility tree — the provider name is next to it', () => {
for (const [id, Icon] of marks) {
expect(renderMark(Icon).getAttribute('aria-hidden'), id).toBe('true')
}
})
it('FE-DOCSYNC-ICONS-007: sizes itself to 20px when nothing says otherwise', () => {
for (const [id, Icon] of marks) {
const svg = renderMark(Icon)
expect(svg.getAttribute('width'), id).toBe('20')
expect(svg.getAttribute('height'), id).toBe('20')
}
})
it('FE-DOCSYNC-ICONS-008: honours an explicit size', () => {
for (const [id, Icon] of marks) {
const svg = renderMark(Icon, { size: 32 })
expect(svg.getAttribute('width'), id).toBe('32')
expect(svg.getAttribute('height'), id).toBe('32')
}
})
it('FE-DOCSYNC-ICONS-009: drops its own width and height once a className sizes it', () => {
// Callers pass utility classes (`h-6 w-6`); a width attribute would win over
// them on some of these and leave a 20px glyph in a 24px box.
for (const [id, Icon] of marks) {
const svg = renderMark(Icon, { className: 'h-6 w-6', size: 32 })
expect(svg.getAttribute('width'), id).toBeNull()
expect(svg.getAttribute('height'), id).toBeNull()
}
})
it('FE-DOCSYNC-ICONS-010: puts the caller className on the svg', () => {
for (const [id, Icon] of marks) {
expect(renderMark(Icon, { className: 'h-6 w-6 text-content' }).getAttribute('class'), id).toBe(
'h-6 w-6 text-content',
)
}
})
})
describe('TrekMark', () => {
it('FE-DOCSYNC-ICONS-011: renders TREK\u2019s mark in currentColor', () => {
const { container } = render(<TrekMark />)
const svg = container.querySelector('svg')
expect(svg).not.toBeNull()
expect(svg!.getAttribute('viewBox')).toBe('0 0 512 512')
expect(svg!.querySelectorAll('path[fill="currentColor"]').length).toBeGreaterThan(0)
})
it('FE-DOCSYNC-ICONS-012: takes a className, which is the only thing that sizes it', () => {
const { container } = render(<TrekMark className="h-6 w-6 text-content" />)
const svg = container.querySelector('svg')!
expect(svg.getAttribute('class')).toBe('h-6 w-6 text-content')
expect(svg.getAttribute('width')).toBeNull()
expect(svg.getAttribute('height')).toBeNull()
})
it('FE-DOCSYNC-ICONS-013: forwards the rest of its svg props', () => {
const { container } = render(<TrekMark data-testid="trek-mark" role="img" />)
const svg = container.querySelector('svg')!
expect(svg.getAttribute('data-testid')).toBe('trek-mark')
expect(svg.getAttribute('role')).toBe('img')
})
it('FE-DOCSYNC-ICONS-014: is decorative by default and can be unhidden by the caller', () => {
const { container } = render(<TrekMark />)
expect(container.querySelector('svg')!.getAttribute('aria-hidden')).toBe('true')
const labelled = render(<TrekMark aria-hidden={false} aria-label="TREK" />)
const svg = labelled.container.querySelector('svg')!
expect(svg.getAttribute('aria-hidden')).toBe('false')
expect(svg.getAttribute('aria-label')).toBe('TREK')
})
})
@@ -0,0 +1,137 @@
import React from 'react'
/**
* The document providers' marks, redrawn as single-colour glyphs.
*
* `currentColor` rather than the brands' own palettes, and that is the point:
* they sit in a row of lucide icons and in a list of addon toggles, where a
* full-colour badge among line icons reads as the one thing that is switched
* on. Following the text colour also means they invert with the theme without a
* second asset — black on light, white on dark.
*
* Not in `public/brands/` beside AirTrail and Dawarich, because those are the
* files each project ships, served unmodified. These are deliberately modified:
* a recoloured logo is a different logo, and the trade is made knowingly here
* for legibility in a monochrome row.
*/
interface GlyphProps {
size?: number
className?: string
}
function frame(size: number, className: string | undefined) {
return {
width: className ? undefined : size,
height: className ? undefined : size,
className,
viewBox: '0 0 512 512',
fill: 'none' as const,
xmlns: 'http://www.w3.org/2000/svg',
style: { flexShrink: 0, display: 'block' as const },
'aria-hidden': true,
}
}
/** Paperless-ngx: the leaf, with the shoot curling out of its stem. */
export function PaperlessIcon({ size = 20, className }: GlyphProps): React.ReactElement {
return (
<svg {...frame(size, className)}>
<path
d="M470 18c-38 62-104 104-179 149-63 38-119 77-146 135-25 55-17 119 8 174l-49 76"
stroke="currentColor"
strokeWidth="46"
strokeLinecap="round"
/>
<path
d="M470 18c14 96 2 195-45 273-46 76-124 129-217 146-25-55-33-119-8-174 27-58 83-97 146-135 45-27 88-54 124-86z"
fill="currentColor"
/>
<path
d="M52 214c-24 47-30 106-10 158 15 39 44 70 79 90"
stroke="currentColor"
strokeWidth="44"
strokeLinecap="round"
/>
<path
d="M52 214c33 36 66 76 82 122 12 35 12 73-3 106"
stroke="currentColor"
strokeWidth="30"
strokeLinecap="round"
/>
</svg>
)
}
/** Papra: a sheet with a folded corner and its lines of text. */
export function PapraIcon({ size = 20, className }: GlyphProps): React.ReactElement {
return (
<svg {...frame(size, className)}>
<path
d="M104 24h164l140 140v264a56 56 0 0 1-56 56H104a56 56 0 0 1-56-56V80a56 56 0 0 1 56-56z"
stroke="currentColor"
strokeWidth="56"
strokeLinejoin="round"
/>
<path
d="M268 24v112a28 28 0 0 0 28 28h112"
stroke="currentColor"
strokeWidth="56"
strokeLinejoin="round"
/>
<path d="M140 216h32" stroke="currentColor" strokeWidth="56" strokeLinecap="round" />
<path d="M140 330h216" stroke="currentColor" strokeWidth="56" strokeLinecap="round" />
<path d="M140 442h216" stroke="currentColor" strokeWidth="56" strokeLinecap="round" />
</svg>
)
}
/** Nextcloud: the three rings. */
export function NextcloudIcon({ size = 20, className }: GlyphProps): React.ReactElement {
return (
<svg {...frame(size, className)}>
<circle cx="256" cy="256" r="97" stroke="currentColor" strokeWidth="42" />
<circle cx="74" cy="256" r="56" stroke="currentColor" strokeWidth="38" />
<circle cx="438" cy="256" r="56" stroke="currentColor" strokeWidth="38" />
</svg>
)
}
/** OpenCloud: the open hexagon with the junction inside it. */
export function OpenCloudIcon({ size = 20, className }: GlyphProps): React.ReactElement {
return (
<svg {...frame(size, className)}>
<path d="M26 128 256 8l230 120v72L256 80 26 200z" fill="currentColor" />
<path d="M26 384 256 504l230-120v-72L256 432 26 312z" fill="currentColor" />
<path
d="M140 212v32l100 58v100l16 10 16-10V302l100-58v-32l-16-10-100 58-100-58z"
fill="currentColor"
/>
</svg>
)
}
/** Synology Drive: the wordmark's own glyph, as the addon list already draws it. */
export function SynologyDriveIcon({ size = 20, className }: GlyphProps): React.ReactElement {
return (
<svg
width={className ? undefined : size}
height={className ? undefined : size}
className={className}
viewBox="0 0 24 24"
style={{ flexShrink: 0, display: 'block' }}
aria-hidden
>
<path d="M17.895 11.927a3.196 3.196 0 0 1 .394-1.53l-.008.017a2.677 2.677 0 0 1 1.075-1.108l.014-.007a3.181 3.181 0 0 1 1.523-.382h.05-.003q1.346 0 2.2.871.854.871.86 2.203c0 .895-.29 1.635-.867 2.226s-1.306.886-2.183.886c-.566 0-1.1-.137-1.571-.379l.019.009a2.535 2.535 0 0 1-1.115-1.067l-.007-.013q-.38-.708-.381-1.726zm1.593.083c0 .591.138 1.043.42 1.349a1.365 1.365 0 0 0 2.066.002l.001-.002c.275-.307.413-.764.413-1.357s-.138-1.033-.413-1.342a1.371 1.371 0 0 0-2.066-.001l-.001.002c-.281.306-.42.758-.42 1.345zm-1.602 2.941H16.33v-3.015c0-.635-.032-1.044-.101-1.234a.876.876 0 0 0-.328-.435l-.003-.002a.938.938 0 0 0-.521-.156h-.027.001-.012c-.27 0-.521.084-.727.228l.004-.003a1.115 1.115 0 0 0-.444.576l-.002.008c-.083.248-.121.696-.121 1.359v2.673H12.5V9.027h1.439v.867c.518-.656 1.167-.98 1.952-.98h.021c.335 0 .655.067.946.189l-.016-.006c.261.105.48.268.648.475l.002.003c.141.185.247.404.304.643l.002.012c.057.278.089.597.089.924l-.002.135v-.007zM6.413 9.028h1.654l1.412 4.204 1.376-4.204h1.611l-2.067 5.693-.38 1.038a4.158 4.158 0 0 1-.4.807l.01-.017a1.637 1.637 0 0 1-.422.443l-.005.003c-.17.113-.367.203-.578.26l-.014.003c-.232.064-.499.1-.774.1h-.025.001a4.13 4.13 0 0 1-.911-.105l.028.005-.129-1.229c.198.046.426.074.659.077h.002c.36 0 .628-.106.8-.318a2.27 2.27 0 0 0 .395-.807l.004-.016zM0 12.29l1.592-.149q.147.802.586 1.181.439.379 1.192.375c.528 0 .927-.113 1.197-.335.27-.222.4-.486.4-.782v-.024a.751.751 0 0 0-.167-.474l.001.001c-.113-.132-.309-.252-.59-.347-.193-.074-.631-.191-1.312-.365-.882-.216-1.496-.486-1.85-.804A2.147 2.147 0 0 1 .3 8.936v-.019V8.908c0-.431.132-.831.358-1.163l-.005.007a2.226 2.226 0 0 1 1.003-.826l.015-.005c.442-.184.973-.281 1.602-.281q1.529 0 2.304.676c.516.457.785 1.057.811 1.809l-1.649.055c-.073-.413-.219-.714-.452-.899-.233-.185-.579-.276-1.034-.276-.476 0-.85.098-1.118.298a.59.59 0 0 0-.261.49v.011-.001.002c0 .201.095.379.242.493l.001.001c.205.179.709.36 1.507.546.798.186 1.388.387 1.769.59.374.196.678.48.893.825l.006.01c.214.345.326.786.326 1.305 0 .489-.146.944-.396 1.325l.006-.009c-.264.408-.64.724-1.084.908l-.016.006c-.475.194-1.065.298-1.772.298-1.029 0-1.819-.241-2.373-.722-.554-.481-.879-1.177-.986-2.091z" fill="currentColor" />
</svg>
)
}
/** Keyed by the provider ids in `document_providers`. */
export const DOCUMENT_PROVIDER_ICONS: Record<string, React.ComponentType<GlyphProps>> = {
paperless: PaperlessIcon,
papra: PapraIcon,
nextcloud: NextcloudIcon,
opencloud: OpenCloudIcon,
synologydrive: SynologyDriveIcon,
}
File diff suppressed because one or more lines are too long
+46
View File
@@ -506,6 +506,47 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
will-change: transform, opacity;
}
/* Dokumentenabgleich: die aktive Bahn laeuft, WAEHREND ein Lauf laeuft.
Bewegung als Antwort auf eine Handlung, nicht als Dekoration — sie hoert auf,
sobald der Lauf durch ist, und sagt in der Zwischenzeit, in welche Richtung
gerade etwas unterwegs ist. */
@keyframes trek-docsync-lane {
from { background-position: 0 0; }
to { background-position: 16px 0; }
}
.trek-docsync-lane-run {
background-image: repeating-linear-gradient(
90deg,
currentColor 0 5px,
transparent 5px 16px
);
animation: trek-docsync-lane 700ms linear infinite;
}
.trek-docsync-lane-run-rev {
animation-direction: reverse;
}
/* Die abgeschaltete Richtung bekommt eine Schraffur — dasselbe Zeichen, das eine
gesperrte Flaeche ueberall traegt. Es liest sich als "hier geht nichts durch",
ohne dass die Bahn verschwindet und die Zeile zusammenklappt. */
.trek-docsync-lane-off {
background-image: repeating-linear-gradient(
-45deg,
var(--border-primary) 0 5px,
transparent 5px 20px
);
}
/* Dieselbe Schraffur im Telefon-Schema: die mobilen Flaechen kennen die
Desktop-Randfarbe nicht, und eine Fremdfarbe faellt dort sofort auf. */
.trek-docsync-lane-off-m {
background-image: repeating-linear-gradient(
-45deg,
var(--m-rowbr) 0 5px,
transparent 5px 20px
);
}
/* Mobile-Drawer-Feel — Modal slidet von unten rein, wird unten am Screen angedockt */
@keyframes trek-drawer-enter {
from { opacity: 0; transform: translateY(100%); }
@@ -595,6 +636,11 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
.trek-lowfuel {
animation: none;
}
/* Die Bahn bleibt sichtbar, sie laeuft nur nicht mehr — die Aussage
"hier ist gerade etwas unterwegs" traegt auch im Stillstand. */
.trek-docsync-lane-run {
animation: none;
}
.trek-skeleton {
animation: none;
background: var(--bg-tertiary);
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState, type ComponentType, type ReactNode } from
import { findFocusDayId } from '../../../components/Planner/today'
import {
CalendarDays, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal,
Plane, Plus, Rows3, Route, SlidersHorizontal, Trash2, Upload,
FolderSync, Plane, Plus, Rows3, Route, SlidersHorizontal, Trash2, Upload,
} from 'lucide-react'
import { useTripPlanner } from '../../../pages/tripPlanner/useTripPlanner'
import { pickDockTabs } from './dockTabs'
@@ -116,6 +116,7 @@ export interface MTripShellApi {
exportCostsCsvSignal: number
uploadFilesSignal: number
openFilesTrashSignal: number
openDocSyncSignal: number
}
/**
@@ -226,6 +227,7 @@ export default function MTripShell({
const [exportCostsCsvSignal, setExportCostsCsvSignal] = useState(0)
const [uploadFilesSignal, setUploadFilesSignal] = useState(0)
const [openFilesTrashSignal, setOpenFilesTrashSignal] = useState(0)
const [openDocSyncSignal, setOpenDocSyncSignal] = useState(0)
// The mobile plan is single-day: make sure a day is active once days arrive.
// Only seed once so an intentional deselect elsewhere is not fought. Open on
@@ -378,6 +380,7 @@ export default function MTripShell({
listsTab, setListsTab, collabTab, setCollabTab,
transportsCompact, bookingsCompact,
addExpenseSignal, exportCostsCsvSignal, uploadFilesSignal, openFilesTrashSignal,
openDocSyncSignal,
}
// Splash — same gate as the desktop page, in the mobile design language.
@@ -672,6 +675,9 @@ export default function MTripShell({
{trTab === 'dateien' && (
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center gap-[7px]">
<PrimaryPill icon={<Upload size={13} strokeWidth={2.2} />} label={t('common.upload')} onClick={() => setUploadFilesSignal(s => s + 1)} />
<MIconBtn ariaLabel={t('docsync.title')} onClick={() => setOpenDocSyncSignal(s => s + 1)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<FolderSync size={15} strokeWidth={2} />
</MIconBtn>
<MIconBtn ariaLabel={t('files.trash')} onClick={() => setOpenFilesTrashSignal(s => s + 1)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
<Trash2 size={15} strokeWidth={2} />
</MIconBtn>
@@ -0,0 +1,350 @@
// FE-DOCSYNC-MOBILE-001 to FE-DOCSYNC-MOBILE-016
import type { ComponentProps } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor, within } from '../../../../../tests/helpers/render'
import { docsyncApi } from '../../../../api/client'
import type { DocSyncConnection, DocSyncLink, DocSyncProvider, DocSyncScope } from '../../../../components/Files/docsync/useDocSync'
import MDocSyncSheet from './MDocSyncSheet'
/**
* The phone sheet is markup over `useDocSync`, so everything here goes through
* the one module boundary that hook has: `docsyncApi`. Nothing reaches into the
* component.
*/
const TRIP_ID = 3
const field = (over: Partial<DocSyncProvider['fields'][number]>): DocSyncProvider['fields'][number] => ({
field_key: 'apiToken',
label: 'apiToken',
input_type: 'text',
placeholder: null,
hint: null,
required: true,
secret: true,
...over,
})
const provider = (over: Partial<DocSyncProvider>): DocSyncProvider => ({
id: 'paperless',
name: 'Paperless',
description: null,
icon: 'paperless',
available: true,
fields: [
field({ field_key: 'baseUrl', label: 'url', input_type: 'url', placeholder: 'https://paperless.example', secret: false }),
field({}),
],
...over,
})
const connection = (over: Partial<DocSyncConnection>): DocSyncConnection => ({
id: 5,
providerId: 'paperless',
baseUrl: 'https://paperless.example',
settings: {},
secrets: {},
allowInsecureTls: false,
lastProbeState: 'ok',
lastProbeError: null,
...over,
})
const link = (over: Partial<DocSyncLink> = {}): DocSyncLink => ({
id: 11,
connectionId: 5,
providerId: 'paperless',
scopeKey: 'tag:trek-3',
remoteLabel: 'TREK trip 3',
remoteRootPath: null,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
lastSyncAt: null,
lastSyncState: 'never',
lastSyncError: null,
webhookUrl: null,
...over,
})
const scope = (over: Partial<DocSyncScope>): DocSyncScope => ({
scopeKey: 'folder:1',
label: 'Photos',
remoteRootId: '1',
remoteRootPath: '/Photos',
...over,
})
/** Server state the spies read, so a mutation can change what the next load returns. */
let providers: DocSyncProvider[]
let connections: DocSyncConnection[]
let links: DocSyncLink[]
let scopes: DocSyncScope[]
const onClose = vi.fn()
function renderSheet(over: Partial<ComponentProps<typeof MDocSyncSheet>> = {}) {
return render(
<MDocSyncSheet tripId={TRIP_ID} tripTitle="Rome" isOwner open onClose={onClose} {...over} />,
)
}
/** List → detail for the one bound store. */
async function openDetail() {
fireEvent.click(await screen.findByRole('button', { name: /Paperless/ }))
await screen.findByRole('button', { name: 'Sync now' })
}
const lane = (name: RegExp) => screen.getByRole('button', { name })
beforeEach(() => {
onClose.mockClear()
providers = [
provider({}),
provider({ id: 'nextcloud', name: 'Nextcloud', fields: [field({ field_key: 'appPassword', label: 'appPassword' })] }),
provider({ id: 'papra', name: 'Papra', fields: [field({ field_key: 'apiKey', label: 'apiKey' })] }),
]
connections = [connection({}), connection({ id: 6, providerId: 'nextcloud' })]
links = [link()]
scopes = [scope({}), scope({ scopeKey: 'folder:2', label: 'Invoices', remoteRootId: '2', remoteRootPath: '/Invoices' })]
vi.spyOn(docsyncApi, 'providers').mockImplementation(async () => providers)
vi.spyOn(docsyncApi, 'listConnections').mockImplementation(async () => connections)
vi.spyOn(docsyncApi, 'listLinks').mockImplementation(async () => links.map(l => ({ ...l })))
vi.spyOn(docsyncApi, 'status').mockImplementation(async () => ({
items: {},
links: links.map(l => ({ id: l.id, holdings: { inTrek: 4, atProvider: 2, paired: 2, missing: 0 } })),
}))
vi.spyOn(docsyncApi, 'listScopes').mockImplementation(async () => ({ scopes }))
vi.spyOn(docsyncApi, 'updateLink').mockResolvedValue({})
vi.spyOn(docsyncApi, 'deleteLink').mockImplementation(async () => { links = []; return {} })
vi.spyOn(docsyncApi, 'syncNow').mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })
vi.spyOn(docsyncApi, 'testConnection').mockResolvedValue({ connected: true })
vi.spyOn(docsyncApi, 'saveConnection').mockResolvedValue({})
vi.spyOn(docsyncApi, 'createScope').mockResolvedValue(scope({}))
vi.spyOn(docsyncApi, 'createLink').mockResolvedValue({})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('MDocSyncSheet — opening and the store list', () => {
it('FE-DOCSYNC-MOBILE-001: renders nothing and asks the server nothing while it is closed', () => {
renderSheet({ open: false })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(docsyncApi.providers).not.toHaveBeenCalled()
expect(docsyncApi.listLinks).not.toHaveBeenCalled()
})
it('FE-DOCSYNC-MOBILE-002: shows a spinner until the first load lands', async () => {
let arrive!: (v: DocSyncProvider[]) => void
vi.spyOn(docsyncApi, 'providers').mockReturnValue(new Promise(r => { arrive = r }))
renderSheet()
expect(document.querySelector('.animate-spin')).not.toBeNull()
expect(screen.queryByRole('button', { name: /Paperless/ })).not.toBeInTheDocument()
arrive(providers)
expect(await screen.findByRole('button', { name: /Paperless/ })).toBeInTheDocument()
expect(document.querySelector('.animate-spin')).toBeNull()
})
it('FE-DOCSYNC-MOBILE-003: lists the bound store and, separately, the ones that could be added', async () => {
renderSheet()
const bound = await screen.findByRole('button', { name: /Paperless/ })
expect(bound).toHaveAccessibleName(expect.stringContaining('TREK trip 3'))
expect(screen.getByText('This trip')).toBeInTheDocument()
expect(screen.getByText('Add another')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Nextcloud/ })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Papra/ })).toBeInTheDocument()
// The trip's own name rides along under the sheet title.
expect(screen.getByRole('dialog', { name: 'Document sync' })).toBeInTheDocument()
expect(screen.getByText('Rome')).toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-004: an instance with no providers says so instead of listing nothing', async () => {
providers = []
renderSheet()
expect(await screen.findByText('No document providers are available')).toBeInTheDocument()
expect(screen.queryByText('This trip')).not.toBeInTheDocument()
})
})
describe('MDocSyncSheet — moving between the views', () => {
it('FE-DOCSYNC-MOBILE-005: tapping a bound store opens its detail view', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: /Paperless/ }))
expect(await screen.findByRole('button', { name: 'Sync now' })).toBeInTheDocument()
expect(screen.getByText('Folder')).toBeInTheDocument()
expect(screen.getByText('TREK trip 3')).toBeInTheDocument()
expect(screen.getByText('not run yet')).toBeInTheDocument()
// The list is gone, not merely scrolled past.
expect(screen.queryByText('Add another')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-006: the back arrow returns to the list, and the list only has a back arrow once you have left it', async () => {
renderSheet()
await screen.findByRole('button', { name: /Paperless/ })
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument()
await openDetail()
fireEvent.click(screen.getByRole('button', { name: 'Back' }))
expect(screen.getByText('Add another')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-007: reopening the sheet starts at the list again, not where it was left', async () => {
const { rerender } = renderSheet()
await openDetail()
rerender(<MDocSyncSheet tripId={TRIP_ID} tripTitle="Rome" isOwner open={false} onClose={onClose} />)
rerender(<MDocSyncSheet tripId={TRIP_ID} tripTitle="Rome" isOwner open onClose={onClose} />)
expect(await screen.findByText('Add another')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Back' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-008: an addable store with no connection asks for credentials first', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: /Papra/ }))
expect(await screen.findByRole('button', { name: 'Test connection' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
expect(screen.getByText('API key')).toBeInTheDocument()
expect(docsyncApi.listScopes).not.toHaveBeenCalled()
// The header now names the store being connected.
expect(screen.getByRole('dialog', { name: 'Document sync' })).toBeInTheDocument()
expect(screen.getByText('Papra')).toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-009: an addable store that already has a connection goes straight to the folder view', async () => {
renderSheet()
fireEvent.click(await screen.findByRole('button', { name: /Nextcloud/ }))
expect(await screen.findByRole('button', { name: /Invoices/ })).toBeInTheDocument()
expect(docsyncApi.listScopes).toHaveBeenCalledWith(TRIP_ID, 6, undefined)
expect(screen.queryByRole('button', { name: 'Test connection' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Photos/ })).toBeInTheDocument()
// A folder name is suggested from the trip so nobody has to invent one.
expect(screen.getByDisplayValue('rome-3')).toBeInTheDocument()
})
})
describe('MDocSyncSheet — the two lanes', () => {
it('FE-DOCSYNC-MOBILE-010: switching a lane off narrows the direction to the other one', async () => {
renderSheet()
await openDetail()
expect(lane(/Out to the store/)).toHaveAttribute('aria-pressed', 'true')
expect(lane(/In from the store/)).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(lane(/Out to the store/))
await waitFor(() => expect(docsyncApi.updateLink).toHaveBeenCalledWith(TRIP_ID, 11, { direction: 'pull' }))
})
it('FE-DOCSYNC-MOBILE-011: the last remaining lane cannot be switched off, and the dead one carries the hatch', async () => {
links = [link({ direction: 'push' })]
renderSheet()
await openDetail()
const out = lane(/Out to the store/)
const back = lane(/In from the store/)
expect(out).toHaveAttribute('aria-pressed', 'true')
expect(back).toHaveAttribute('aria-pressed', 'false')
expect(back.className).toContain('trek-docsync-lane-off-m')
expect(out.className).not.toContain('trek-docsync-lane-off-m')
fireEvent.click(out)
expect(docsyncApi.updateLink).not.toHaveBeenCalled()
expect(lane(/Out to the store/)).toHaveAttribute('aria-pressed', 'true')
// The off lane can still be switched back on.
fireEvent.click(back)
await waitFor(() => expect(docsyncApi.updateLink).toHaveBeenCalledWith(TRIP_ID, 11, { direction: 'both' }))
})
})
describe('MDocSyncSheet — what the owner may do', () => {
it('FE-DOCSYNC-MOBILE-012: the auto-sync switch patches the binding', async () => {
renderSheet()
await openDetail()
const auto = screen.getByRole('switch', { name: 'Sync automatically' })
expect(auto).toBeChecked()
fireEvent.click(auto)
await waitFor(() => expect(docsyncApi.updateLink).toHaveBeenCalledWith(TRIP_ID, 11, { syncEnabled: false }))
})
it('FE-DOCSYNC-MOBILE-013: disconnecting asks first, then unbinds and drops back to the list', async () => {
renderSheet()
await openDetail()
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }))
const confirm = screen.getByRole('dialog', { name: 'Disconnect' })
expect(docsyncApi.deleteLink).not.toHaveBeenCalled()
fireEvent.click(within(confirm).getByRole('button', { name: 'Disconnect' }))
await waitFor(() => expect(docsyncApi.deleteLink).toHaveBeenCalledWith(TRIP_ID, 11))
expect(await screen.findByText('Connect a provider')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Sync now' })).not.toBeInTheDocument()
})
it('FE-DOCSYNC-MOBILE-014: Sync now runs that binding', async () => {
renderSheet()
await openDetail()
fireEvent.click(screen.getByRole('button', { name: 'Sync now' }))
await waitFor(() => expect(docsyncApi.syncNow).toHaveBeenCalledWith(TRIP_ID, 11, false))
})
})
describe('MDocSyncSheet — what a member may do', () => {
it('FE-DOCSYNC-MOBILE-015: a member sees no settings and no disconnect, and the lanes are inert', async () => {
renderSheet({ isOwner: false })
await openDetail()
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Disconnect' })).not.toBeInTheDocument()
expect(screen.queryByText('When a document is deleted')).not.toBeInTheDocument()
expect(lane(/Out to the store/)).toBeDisabled()
expect(lane(/In from the store/)).toBeDisabled()
fireEvent.click(lane(/Out to the store/))
expect(docsyncApi.updateLink).not.toHaveBeenCalled()
// Reading and running it is still theirs.
expect(screen.getByRole('button', { name: 'Sync now' })).toBeEnabled()
})
it('FE-DOCSYNC-MOBILE-016: a member is not offered stores to add, and an unbound trip explains who sets it up', async () => {
links = []
renderSheet({ isOwner: false })
expect(await screen.findByText('Nothing connected yet')).toBeInTheDocument()
expect(screen.getByText('The trip owner sets this up. Documents stay in TREK either way.')).toBeInTheDocument()
expect(screen.queryByText('Connect a provider')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Nextcloud/ })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Papra/ })).not.toBeInTheDocument()
})
})
@@ -0,0 +1,719 @@
import { useEffect, useMemo, useState } from 'react'
import {
ArrowLeft, ArrowRight, Check, ChevronLeft, ChevronRight, FolderPlus, FolderSync, Link2Off,
Loader2, Plus, RefreshCw,
} from 'lucide-react'
import MSheet from '../../../components/MSheet'
import MIconBtn from '../../../components/MIconBtn'
import MToggle from '../../../components/MToggle'
import MConfirmSheet from '../../settings/MConfirmSheet'
import { TileHeader } from '../sheets/MTripSheetUi'
import { useTranslation } from '../../../../i18n'
import { DOCUMENT_PROVIDER_ICONS } from '../../../../components/shared/DocumentProviderIcons'
import TrekMark from '../../../../components/shared/TrekMark'
import {
useDocSync, type DocSyncLink, type DocSyncProvider,
} from '../../../../components/Files/docsync/useDocSync'
import { relativeTime } from '../../../../utils/relativeTime'
/**
* Document sync on the phone.
*
* Everything that decides anything lives in `useDocSync`, which the desktop
* panel uses unchanged — this file is markup in the phone's design language and
* nothing else. The two shells are a deliberate mirror, and duplicated logic
* across them is what the 3% duplication budget is spent on.
*
* One sheet with four views rather than four stacked sheets: the flow is
* list → credentials → folder → detail, and a phone that opens a sheet per step
* buries the close button three layers deep.
*/
type View = 'list' | 'detail' | 'connect' | 'scope'
export default function MDocSyncSheet({
tripId,
tripTitle,
isOwner,
open,
onClose,
}: {
tripId: number | string
tripTitle?: string
isOwner: boolean
open: boolean
onClose: () => void
}) {
const { t } = useTranslation()
const sync = useDocSync(tripId, open)
const [view, setView] = useState<View>('list')
const [selected, setSelected] = useState<number | null>(null)
const [pending, setPending] = useState<DocSyncProvider | null>(null)
const [confirmUnlink, setConfirmUnlink] = useState(false)
const bound = useMemo(() => new Set(sync.links.map(l => l.providerId)), [sync.links])
const available = sync.providers.filter(p => !bound.has(p.id))
const link = sync.links.find(l => l.id === selected) ?? null
// A store that was removed elsewhere must not leave the detail view pointing
// at nothing.
useEffect(() => {
if (view === 'detail' && !link) setView('list')
}, [view, link])
// Every open starts at the list: a sheet that reopens three steps deep is a
// sheet somebody has to find their way out of.
useEffect(() => {
if (!open) { setView('list'); setPending(null) }
}, [open])
const openStore = (p: DocSyncProvider) => {
setPending(p)
setView(sync.connectionFor(p.id) ? 'scope' : 'connect')
}
const title =
view === 'connect' || view === 'scope'
? pending?.name ?? t('docsync.title')
: view === 'detail'
? sync.providers.find(p => p.id === link?.providerId)?.name ?? t('docsync.title')
: t('docsync.title')
return (
<>
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('docsync.title')}>
<div className="flex-none px-[18px] pt-4">
<div className="flex items-center gap-2">
{view !== 'list' && (
<MIconBtn variant="neutral" size={34} onClick={() => setView('list')} ariaLabel={t('common.back')}>
<ChevronLeft size={16} strokeWidth={2.2} />
</MIconBtn>
)}
<div className="min-w-0 flex-1">
<TileHeader
icon={<FolderSync size={19} strokeWidth={1.8} />}
title={title}
sub={view === 'list' ? tripTitle : undefined}
onClose={onClose}
closeLabel={t('common.close')}
/>
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
{sync.loading ? (
<div className="flex justify-center py-12">
<Loader2 size={20} className="animate-spin text-m-faint" />
</div>
) : sync.providers.length === 0 ? (
<Empty text={t('docsync.noProviders')} hint={t('docsync.noProvidersHint')} />
) : view === 'connect' && pending ? (
<ConnectView
provider={pending}
sync={sync}
onDone={() => setView('scope')}
/>
) : view === 'scope' && pending && sync.connectionFor(pending.id) ? (
<ScopeView
connectionId={sync.connectionFor(pending.id)!.id}
suggestedName={slugFor(tripTitle, tripId)}
sync={sync}
onBound={() => setView('list')}
/>
) : view === 'detail' && link ? (
<DetailView
link={link}
providerName={sync.providers.find(p => p.id === link.providerId)?.name ?? link.providerId}
sync={sync}
isOwner={isOwner}
onUnlink={() => setConfirmUnlink(true)}
/>
) : (
<ListView
links={sync.links}
providers={sync.providers}
available={available}
isOwner={isOwner}
onOpen={id => { setSelected(id); setView('detail') }}
onAdd={openStore}
/>
)}
</div>
</MSheet>
<MConfirmSheet
open={confirmUnlink}
onClose={() => setConfirmUnlink(false)}
title={t('docsync.unlink')}
message={t('docsync.confirmUnlink')}
confirmLabel={t('docsync.unlink')}
cancelLabel={t('common.cancel')}
danger
onConfirm={() => {
setConfirmUnlink(false)
if (link) void sync.removeLink(link.id).then(() => setView('list'))
}}
/>
</>
)
}
/** The stores this trip uses, then the ones it could. */
function ListView({
links,
providers,
available,
isOwner,
onOpen,
onAdd,
}: {
links: DocSyncLink[]
providers: DocSyncProvider[]
available: DocSyncProvider[]
isOwner: boolean
onOpen: (id: number) => void
onAdd: (p: DocSyncProvider) => void
}) {
const { t } = useTranslation()
return (
<div className="mt-3 flex flex-col gap-4">
{links.length > 0 && (
<section>
<SectionLabel>{t('docsync.sidebar.connected')}</SectionLabel>
<div className="flex flex-col gap-2">
{links.map(l => (
<StoreRow
key={l.id}
providerId={l.providerId}
title={providers.find(p => p.id === l.providerId)?.name || l.providerId}
sub={l.remoteLabel || l.remoteRootPath || l.scopeKey}
state={l.lastSyncState}
onClick={() => onOpen(l.id)}
/>
))}
</div>
</section>
)}
{isOwner && available.length > 0 && (
<section>
<SectionLabel>{links.length === 0 ? t('docsync.addProvider') : t('docsync.addAnother')}</SectionLabel>
<div className="flex flex-col gap-2">
{available.map(p => (
<StoreRow
key={p.id}
providerId={p.id}
title={p.name}
sub={t(`docsync.model.${p.id}`)}
addable
onClick={() => onAdd(p)}
/>
))}
</div>
</section>
)}
{links.length === 0 && !isOwner && (
<Empty text={t('docsync.empty.title')} hint={t('docsync.empty.hintMember')} />
)}
</div>
)
}
/** One binding: the flow, a run button, its settings. */
function DetailView({
link,
providerName,
sync,
isOwner,
onUnlink,
}: {
link: DocSyncLink
providerName: string
sync: ReturnType<typeof useDocSync>
isOwner: boolean
onUnlink: () => void
}) {
const { t, language } = useTranslation()
const busy = sync.busy === `sync-${link.id}`
const holdings = link.holdings ?? { inTrek: 0, atProvider: 0, paired: 0, missing: 0 }
const pushOn = link.direction === 'both' || link.direction === 'push'
const pullOn = link.direction === 'both' || link.direction === 'pull'
const ProviderIcon = DOCUMENT_PROVIDER_ICONS[link.providerId]
/** The last remaining direction cannot be switched off. */
const toggle = (lane: 'push' | 'pull') => {
if (!isOwner) return
const nextPush = lane === 'push' ? !pushOn : pushOn
const nextPull = lane === 'pull' ? !pullOn : pullOn
if (!nextPush && !nextPull) return
void sync.updateLink(link.id, { direction: nextPush && nextPull ? 'both' : nextPush ? 'push' : 'pull' })
}
const ranAt = link.lastSyncAt ? Date.parse(link.lastSyncAt) : NaN
return (
<div className="mt-3 flex flex-col gap-3">
{/* The two ends, side by side, with the lanes stacked under them: the
desktop bar puts them left and right of each other, which needs width
a phone does not have. */}
<section className="rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop p-3">
<div className="flex items-stretch gap-2">
<EndBox glyph={<TrekMark className="h-[18px] w-[18px]" />} count={holdings.inTrek} name={t('docsync.flow.trek')} />
<EndBox
glyph={ProviderIcon ? <ProviderIcon className="h-[18px] w-[18px]" /> : null}
count={holdings.atProvider}
name={providerName}
/>
</div>
<div className="mt-2 flex flex-col gap-2">
<LaneRow
active={pushOn}
label={t('docsync.flow.toProvider')}
icon={<ArrowRight size={13} strokeWidth={2.6} />}
disabled={!isOwner}
onClick={() => toggle('push')}
/>
<LaneRow
active={pullOn}
label={t('docsync.flow.toTrek')}
icon={<ArrowLeft size={13} strokeWidth={2.6} />}
disabled={!isOwner}
onClick={() => toggle('pull')}
/>
</div>
</section>
<Row label={t('docsync.binding.folder')} value={link.remoteLabel || link.remoteRootPath || link.scopeKey} />
<Row
label={t('docsync.binding.lastRun')}
value={Number.isNaN(ranAt) ? t('docsync.binding.neverRun') : relativeTime(ranAt, language)}
/>
{link.lastSyncState !== 'ok' && link.lastSyncState !== 'never' && (
<p className="rounded-2xl bg-[color:var(--m-st-warn-bg,var(--m-ic))] px-3 py-2 font-geist text-[0.6875rem] text-[color:var(--m-st-warn,var(--m-muted))]">
{t(`docsync.linkState.${link.lastSyncState}`)}
{link.lastSyncError ? ` · ${t(`docsync.error.${link.lastSyncError}`)}` : ''}
</p>
)}
<button
type="button"
onClick={() => void sync.syncNow(link.id)}
disabled={busy}
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl bg-m-act text-[0.8125rem] font-bold text-m-actfg disabled:opacity-60"
>
<RefreshCw size={15} strokeWidth={2.2} className={busy ? 'animate-spin' : ''} />
{busy ? t('docsync.syncing') : t('docsync.syncNow')}
</button>
{isOwner && (
<>
<SettingRow label={t('docsync.syncEnabled')} hint={t('docsync.binding.autoHint')}>
<MToggle
checked={link.syncEnabled}
ariaLabel={t('docsync.syncEnabled')}
onChange={() => void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })}
/>
</SettingRow>
<SettingRow label={t('docsync.deletePolicy')} hint={t('docsync.binding.deleteHint')}>
<button
type="button"
onClick={() =>
void sync.updateLink(link.id, { deletePolicy: link.deletePolicy === 'unlink' ? 'trash' : 'unlink' })
}
className="rounded-full bg-[color:var(--m-ic)] px-3 py-[6px] font-geist text-[0.6875rem] font-bold text-m-ink"
>
{link.deletePolicy === 'unlink' ? t('docsync.deleteUnlink') : t('docsync.deleteTrash')}
</button>
</SettingRow>
<button
type="button"
onClick={onUnlink}
className="flex h-11 w-full items-center justify-center gap-2 rounded-2xl border border-[color:var(--m-rowbr)] text-[0.8125rem] font-bold text-[color:var(--m-st-danger)]"
>
<Link2Off size={15} strokeWidth={2.2} />
{t('docsync.unlink')}
</button>
</>
)}
</div>
)
}
/** Credentials for a store this instance offers but the trip has not used yet. */
function ConnectView({
provider,
sync,
onDone,
}: {
provider: DocSyncProvider
sync: ReturnType<typeof useDocSync>
onDone: () => void
}) {
const { t } = useTranslation()
const [values, setValues] = useState<Record<string, string>>({})
const [verdict, setVerdict] = useState<{ connected: boolean; account?: string; error?: string } | null>(null)
const baseUrl = values.baseUrl ?? ''
const credentials = Object.fromEntries(Object.entries(values).filter(([k]) => k !== 'baseUrl'))
const missing = provider.fields.some(f => f.required && !(values[f.field_key] || '').trim())
const probe = async () => {
setVerdict(await sync.testConnection(provider.id, baseUrl, credentials, false))
}
const save = async () => {
if (await sync.saveConnection(provider.id, baseUrl, credentials, false)) onDone()
}
return (
<div className="mt-3 flex flex-col gap-3">
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t(`docsync.connect.about.${provider.id}`)}</p>
{provider.fields.map(f => (
<label key={f.field_key} className="flex flex-col gap-[6px]">
<span className="font-geist text-[0.6875rem] font-bold text-m-muted">
{t(`docsync.provider${cap(f.label)}`)}
{f.required && <span className="text-[color:var(--m-st-danger)]"> *</span>}
</span>
<input
type={f.secret ? 'password' : f.input_type === 'url' ? 'url' : 'text'}
value={values[f.field_key] ?? ''}
onChange={e => { setValues(v => ({ ...v, [f.field_key]: e.target.value })); setVerdict(null) }}
placeholder={f.placeholder ?? ''}
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
className="h-11 rounded-2xl border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-3 text-[0.8125rem] text-m-ink outline-none"
/>
{f.hint && <span className="font-geist text-[0.625rem] text-m-faint">{f.hint}</span>}
</label>
))}
{verdict && (
<p
className={`rounded-2xl px-3 py-2 font-geist text-[0.6875rem] ${
verdict.connected ? 'text-[color:var(--m-st-ok,var(--m-ink))]' : 'text-[color:var(--m-st-danger)]'
} bg-[color:var(--m-ic)]`}
>
{verdict.connected
? verdict.account
? t('docsync.connect.okAs', { account: verdict.account })
: t('docsync.connected')
: t(`docsync.error.${verdict.error ?? 'unknown'}`)}
</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={() => void probe()}
disabled={missing || sync.busy === 'test'}
className="h-11 flex-1 rounded-2xl border border-[color:var(--m-rowbr)] text-[0.8125rem] font-bold text-m-ink disabled:opacity-50"
>
{sync.busy === 'test' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('docsync.test')}
</button>
<button
type="button"
onClick={() => void save()}
disabled={missing || sync.busy === 'save'}
className="h-11 flex-1 rounded-2xl bg-m-act text-[0.8125rem] font-bold text-m-actfg disabled:opacity-50"
>
{sync.busy === 'save' ? <Loader2 size={15} className="mx-auto animate-spin" /> : t('common.save')}
</button>
</div>
</div>
)
}
/** Which folder, tag or space this trip gets. */
function ScopeView({
connectionId,
suggestedName,
sync,
onBound,
}: {
connectionId: number
suggestedName: string
sync: ReturnType<typeof useDocSync>
onBound: () => void
}) {
const { t } = useTranslation()
const [scopes, setScopes] = useState<Awaited<ReturnType<typeof sync.loadScopes>>['scopes'] | null>(null)
const [error, setError] = useState<string | null>(null)
const [name, setName] = useState(suggestedName)
const [working, setWorking] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
void (async () => {
const res = await sync.loadScopes(connectionId)
if (cancelled) return
setScopes(res.scopes)
setError(res.error ?? null)
})()
return () => { cancelled = true }
}, [connectionId, sync])
const bind = async (scope: { scopeKey: string; label: string; remoteRootId: string | null; remoteRootPath: string | null }) => {
setWorking(scope.scopeKey)
const ok = await sync.createLink({
connectionId,
scopeKey: scope.scopeKey,
remoteRootId: scope.remoteRootId,
remoteRootPath: scope.remoteRootPath,
remoteLabel: scope.label,
direction: 'both',
deletePolicy: 'unlink',
conflictPolicy: 'manual',
syncEnabled: true,
})
setWorking(null)
if (ok) onBound()
}
return (
<div className="mt-3 flex flex-col gap-3">
<p className="font-geist text-[0.71875rem] leading-snug text-m-muted">{t('docsync.scope.intro')}</p>
<div className="flex gap-2">
<input
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('docsync.newFolderPlaceholder')}
className="h-11 min-w-0 flex-1 rounded-2xl border border-[color:var(--m-inbr)] bg-[color:var(--m-inner)] px-3 text-[0.8125rem] text-m-ink outline-none"
/>
<button
type="button"
onClick={() => {
const trimmed = name.trim()
if (!trimmed) return
setWorking('__new__')
void sync.createScope(connectionId, trimmed)
.then(scope => { if (scope) return bind(scope) })
.finally(() => setWorking(null))
}}
disabled={!name.trim() || working !== null}
className="flex h-11 flex-none items-center gap-2 rounded-2xl bg-m-act px-4 text-[0.8125rem] font-bold text-m-actfg disabled:opacity-50"
>
{working === '__new__' ? <Loader2 size={15} className="animate-spin" /> : <FolderPlus size={15} strokeWidth={2.2} />}
{t('docsync.scope.createAction')}
</button>
</div>
{sync.error && (
<p className="rounded-2xl bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.6875rem] text-[color:var(--m-st-danger)]">
{t(`docsync.error.${sync.error}`)}
</p>
)}
<SectionLabel>{t('docsync.scope.pickTitle')}</SectionLabel>
{scopes === null ? (
<div className="flex justify-center py-8"><Loader2 size={18} className="animate-spin text-m-faint" /></div>
) : error ? (
<p className="rounded-2xl bg-[color:var(--m-ic)] px-3 py-2 font-geist text-[0.6875rem] text-[color:var(--m-st-danger)]">
{t(`docsync.error.${error}`)}
</p>
) : scopes.length === 0 ? (
<Empty text={t('docsync.noFolders')} />
) : (
<div className="flex flex-col gap-2">
{scopes.map(s => (
<button
key={s.scopeKey}
type="button"
onClick={() => void bind(s)}
disabled={working !== null}
className="flex items-center gap-[10px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[9px] text-left disabled:opacity-50"
>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.78125rem] font-semibold text-m-ink">{s.label}</span>
{s.remoteRootPath && (
<span className="mt-[2px] block truncate font-geist text-[0.625rem] text-m-faint">{s.remoteRootPath}</span>
)}
</span>
{working === s.scopeKey
? <Loader2 size={14} className="flex-none animate-spin text-m-faint" />
: <ChevronRight size={15} strokeWidth={2} className="flex-none text-m-faint" />}
</button>
))}
</div>
)}
</div>
)
}
/** One store in the list, connected or not. */
function StoreRow({
providerId,
title,
sub,
state,
addable,
onClick,
}: {
providerId: string
title: string
sub: string
state?: string
addable?: boolean
onClick: () => void
}) {
const Icon = DOCUMENT_PROVIDER_ICONS[providerId]
return (
<button
type="button"
onClick={onClick}
className="flex items-center gap-[10px] rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[9px] text-left"
>
<span className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] bg-[color:var(--m-ic)] text-m-ink">
{Icon ? <Icon className="h-4 w-4" /> : null}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[0.78125rem] font-semibold text-m-ink">{title}</span>
<span className="mt-[2px] block truncate font-geist text-[0.625rem] text-m-faint">{sub}</span>
</span>
{addable ? (
<Plus size={15} strokeWidth={2} className="flex-none text-m-faint" />
) : (
<span className="flex flex-none items-center gap-2">
{state && <StateDot state={state} />}
<ChevronRight size={15} strokeWidth={2} className="text-m-faint" />
</span>
)}
</button>
)
}
/** One side of the flow: how much it is holding. */
function EndBox({ glyph, count, name }: { glyph: React.ReactNode; count: number; name: string }) {
const { t } = useTranslation()
return (
<div className="flex min-w-0 flex-1 flex-col items-center gap-1 rounded-2xl bg-[color:var(--m-inner)] px-2 py-3">
<span className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-[color:var(--m-ic)] text-m-ink">
{glyph}
</span>
<span className="text-[1.25rem] font-bold leading-none tabular-nums text-m-ink">{count}</span>
<span className="font-geist text-[0.5625rem] uppercase leading-none tracking-wide text-m-faint">
{t('docsync.flow.documents')}
</span>
<span className="w-full truncate text-center font-geist text-[0.625rem] text-m-muted" title={name}>{name}</span>
</div>
)
}
/**
* One direction, on or off.
*
* The off state is the desktop's hatch, scaled for a phone: the same reading of
* "nothing goes through here" without a second vocabulary to learn.
*/
function LaneRow({
active,
label,
icon,
disabled,
onClick,
}: {
active: boolean
label: string
icon: React.ReactNode
disabled?: boolean
onClick: () => void
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-pressed={active}
className={`flex h-11 items-center gap-[10px] overflow-hidden rounded-2xl px-3 text-left ${
active ? 'bg-m-act text-m-actfg' : 'trek-docsync-lane-off-m bg-[color:var(--m-inner)] text-m-faint'
}`}
>
{/* Inverted on the active lane: --m-knob and --m-act are both white in
dark mode, so a knob-coloured tile there hid the arrow inside it. */}
<span
className={`flex h-7 w-7 flex-none items-center justify-center rounded-[9px] ${
active ? 'bg-[color:var(--m-actfg)] text-m-act' : 'bg-[color:var(--m-ic)] text-m-faint'
}`}
>
{icon}
</span>
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold">{label}</span>
{active && <Check size={15} strokeWidth={2.4} className="flex-none" />}
</button>
)
}
function StateDot({ state }: { state: string }) {
const { t } = useTranslation()
const tone =
state === 'ok' ? 'var(--m-st-ok, #22c55e)'
: state === 'partial' ? 'var(--m-st-warn, #f59e0b)'
: state === 'never' ? 'var(--m-trackoff)'
: 'var(--m-st-danger)'
return (
<span
className="h-2 w-2 rounded-full"
style={{ background: tone }}
title={t(`docsync.linkState.${state}`)}
/>
)
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return <div className="mb-2 font-geist text-[0.625rem] font-bold uppercase tracking-wide text-m-faint">{children}</div>
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[9px]">
<span className="font-geist text-[0.6875rem] text-m-muted">{label}</span>
<span className="min-w-0 truncate text-[0.78125rem] font-semibold text-m-ink" title={value}>{value}</span>
</div>
)
}
function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-[color:var(--m-rowbr)] bg-m-sheetop px-3 py-[9px]">
<span className="min-w-0 flex-1">
<span className="block text-[0.78125rem] font-semibold text-m-ink">{label}</span>
{hint && <span className="mt-[2px] block font-geist text-[0.625rem] text-m-faint">{hint}</span>}
</span>
<span className="flex-none">{children}</span>
</div>
)
}
function Empty({ text, hint }: { text: string; hint?: string }) {
return (
<div className="py-10 text-center">
<div className="font-geist text-[0.78125rem] text-m-muted">{text}</div>
{hint && <div className="mx-auto mt-1 max-w-[16rem] font-geist text-[0.625rem] leading-snug text-m-faint">{hint}</div>}
</div>
)
}
/** `apiToken` → `ApiToken`, so the field's stored label suffix becomes its key. */
function cap(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1)
}
/** A folder name from the trip's own title, so nobody has to invent one. */
function slugFor(title: string | undefined, tripId: number | string): string {
const base = (title || 'trek')
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40)
return `${base || 'trek'}-${tripId}`
}
@@ -11,6 +11,9 @@ import type { MTabScreenProps } from './tabModel'
import MFileMenuSheet from './MFileMenuSheet'
import MFileLinkSheet from './MFileLinkSheet'
import MFileTrashSheet from './MFileTrashSheet'
import MDocSyncSheet from './MDocSyncSheet'
import { canManageDocSync } from '../../../../components/Files/docsync/useDocSync'
import { useAuthStore } from '../../../../store/authStore'
import MFileLightbox from './MFileLightbox'
import {
FILE_FILTERS, buildFileLinkLabels, formatFileDate, getFileTypeMeta,
@@ -36,7 +39,9 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) {
const [filter, setFilter] = useState<FileFilterId>('all')
const [menuFileId, setMenuFileId] = useState<number | null>(null)
const [linkFileId, setLinkFileId] = useState<number | null>(null)
const currentUser = useAuthStore(st => st.user)
const [trashOpen, setTrashOpen] = useState(false)
const [docSyncOpen, setDocSyncOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
const [uploading, setUploading] = useState(false)
@@ -110,6 +115,15 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) {
lastTrashSignal.current = shell.openFilesTrashSignal
}, [shell.openFilesTrashSignal])
// ── Document sync: same signal pattern again. ──
const lastDocSyncSignal = useRef(shell.openDocSyncSignal)
useEffect(() => {
if (shell.openDocSyncSignal !== lastDocSyncSignal.current && shell.openDocSyncSignal > 0) {
setDocSyncOpen(true)
}
lastDocSyncSignal.current = shell.openDocSyncSignal
}, [shell.openDocSyncSignal])
// ── Star toggle (ungated, §7.6) — direct filesApi call + store refresh, same as §7.3. ──
const toggleStar = async (file: TripFile) => {
try {
@@ -211,6 +225,13 @@ export default function MFilesTab({ planner, shell }: MTabScreenProps) {
/>
<MFileLinkSheet planner={planner} file={linkFile} onClose={() => setLinkFileId(null)} />
<MFileTrashSheet planner={planner} open={trashOpen} onClose={() => setTrashOpen(false)} />
<MDocSyncSheet
tripId={planner.tripId}
tripTitle={planner.trip?.title}
isOwner={canManageDocSync(currentUser, planner.trip)}
open={docSyncOpen}
onClose={() => setDocSyncOpen(false)}
/>
{lightboxIndex != null && mediaFiles.length > 0 && (
<MFileLightbox
files={mediaFiles}
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useRef } from 'react'
import { addListener, removeListener } from '../api/websocket'
import { isEffectivelyOnline, onNetworkModeChange } from './networkMode'
/**
* How long pings are collected before one refetch goes out.
*
* Long enough to fold a burst into a single request — clearing twelve checked
* items issues twelve deletes, and an import writes a whole list — short enough
* that a single edit still reads as instant.
*/
const COALESCE_MS = 250
/**
* Refetch when the server says something moved, without trusting the event to
* carry what moved.
*
* Some events are content-free on purpose: bag weights are summed across members
* whose items this client may not see, and a document-sync run reports counts
* that are only meaningful read back from the server. Every such ping is a
* refetch, which is why this coalesces bursts and never lets two requests
* overlap — the server pings the whole room including the originating socket, so
* a busy trip would otherwise have every client refetching once per written row.
*
* A ping sent while this client is disconnected is gone for good, so the two
* moments where that can have happened count as pings of their own: the room
* re-join after a reconnect, and the return to online.
*
* `reload` may change identity every render; the latest one is always used.
*/
export function useServerPing(eventName: string, enabled: boolean, reload: () => Promise<void>): void {
const reloadRef = useRef(reload)
reloadRef.current = reload
useEffect(() => {
if (!enabled) return
let timer: ReturnType<typeof setTimeout> | null = null
let inFlight = false
/** A ping that arrived while a refetch was running: serve it once that lands. */
let pendingAfterFlight = false
let cancelled = false
const run = (): void => {
if (cancelled) return
if (inFlight) {
pendingAfterFlight = true
return
}
inFlight = true
void reloadRef.current().finally(() => {
inFlight = false
if (pendingAfterFlight && !cancelled) {
pendingAfterFlight = false
run()
}
})
}
const schedule = (): void => {
if (timer) return // already collecting this burst
timer = setTimeout(() => {
timer = null
run()
}, COALESCE_MS)
}
const handler = (event: Record<string, unknown>) => {
// 'joined' is the room re-join the socket sends on every reconnect, so it
// is also the moment what is on screen is what was there before the drop:
// whatever moved in the meantime pinged into a closed socket.
if (event.type !== eventName && event.type !== 'joined') return
schedule()
}
let wasOnline = isEffectivelyOnline()
const onMode = (): void => {
const online = isEffectivelyOnline()
// Only the way back matters: that is when the surfaces stop summing what
// this client can see and go back to trusting the server numbers.
if (online && !wasOnline) schedule()
wasOnline = online
}
addListener(handler)
const unsubscribeMode = onNetworkModeChange(onMode)
return () => {
cancelled = true
removeListener(handler)
unsubscribeMode()
if (timer) clearTimeout(timer)
}
}, [enabled, eventName])
}
+1
View File
@@ -325,6 +325,7 @@ export function buildShell(overrides: Partial<MTripShellApi> = {}): MTripShellAp
exportCostsCsvSignal: 0,
uploadFilesSignal: 0,
openFilesTrashSignal: 0,
openDocSyncSignal: 0,
};
return { ...base, ...overrides };
}
@@ -74,8 +74,14 @@ export interface LinkRow {
interface ProviderFieldRow {
field_key: string;
/** i18n key suffix, never display text — the client resolves `docsync.<label>`. */
label: string;
input_type: string;
placeholder: string | null;
hint: string | null;
secret: number;
required: number;
sort_order: number;
}
@Injectable()
@@ -96,8 +102,14 @@ export class DocSyncConfigService {
}
providerFields(providerId: string): ProviderFieldRow[] {
// Every column the form needs, not just the three the secret bookkeeping
// uses: the client renders the field from this row, and a missing `label`
// turns into a literal "docsync.undefined" on screen.
return this.db.connection
.prepare('SELECT field_key, secret, required FROM document_provider_fields WHERE provider_id = ? ORDER BY sort_order')
.prepare(
`SELECT field_key, label, input_type, placeholder, hint, secret, required, sort_order
FROM document_provider_fields WHERE provider_id = ? ORDER BY sort_order`,
)
.all(providerId) as ProviderFieldRow[];
}
@@ -1,7 +1,8 @@
import { Controller, HttpCode, Param, Post, Req } from '@nestjs/common';
import { Controller, HttpCode, OnModuleDestroy, Param, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import crypto from 'crypto';
import { Public } from '../auth/public.decorator';
import { WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from './doc-sync.constants';
import { DocSyncConfigService } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
@@ -22,12 +23,29 @@ import { DocSyncService } from './doc-sync.service';
* supports it, a shared secret is checked as well.
*/
@Controller('api/docsync/webhook')
export class DocSyncWebhookController {
export class DocSyncWebhookController implements OnModuleDestroy {
/**
* One pending run per binding, so a burst folds into a single pass.
*
* Providers fire per document: dropping twenty files into a watched Nextcloud
* folder is twenty calls within a second or two. Each one used to start its
* own run, which the service's in-flight guard then answered with `busy` —
* so nineteen changes were announced and thrown away, and the one run that
* did start had begun before most of them landed. Collecting them for a beat
* and then running once is both less work and more correct.
*/
private readonly pending = new Map<number, ReturnType<typeof setTimeout>>();
constructor(
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
) {}
onModuleDestroy(): void {
for (const timer of this.pending.values()) clearTimeout(timer);
this.pending.clear();
}
@Post(':token')
@Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.')
@HttpCode(200)
@@ -42,11 +60,34 @@ export class DocSyncWebhookController {
// Fire and forget. Paperless allows five seconds before it counts the call
// as failed and retries, and a sync run takes longer than that whenever
// there is anything to do.
void this.sync.syncLink(link);
// there is anything to do — so the answer goes out now and the run happens
// after the debounce window, by which time the rest of the burst has
// arrived and been folded into this same timer.
this.schedule(link.id, () => this.config.getLink(link.id));
return { received: true };
}
/**
* Start one run per binding per window, trailing rather than leading.
*
* Trailing on purpose: the first call of a burst is the least informed one,
* because the provider is usually still writing the rest. The link is looked
* up again when the timer fires, so a binding switched off or deleted in the
* meantime does not get one last run out of a stale row.
*/
private schedule(linkId: number, reload: () => ReturnType<DocSyncConfigService['getLink']>): void {
if (this.pending.has(linkId)) return;
const timer = setTimeout(() => {
this.pending.delete(linkId);
const fresh = reload();
if (!fresh || fresh.sync_enabled !== 1) return;
void this.sync.syncLink(fresh);
}, WEBHOOK_NUDGE_DEBOUNCE_SECONDS * 1000);
// A pending nudge must not hold the process open at shutdown.
if (typeof timer.unref === 'function') timer.unref();
this.pending.set(linkId, timer);
}
/**
* Accept either a plain shared-secret header (Paperless workflows, Nextcloud
* `authMethod: header`) or Papra's standard-webhooks HMAC. Compared in
@@ -77,4 +77,8 @@ export const MAX_TRANSFERS_PER_RUN = 25;
/** app_settings keys, read per tick so an admin toggle needs no restart. */
export const SETTING_POLL_INTERVAL = 'docsync_poll_interval_seconds';
export const SETTING_SYNC_ENABLED = 'docsync_sync_enabled';
export const SETTING_MAX_FILE_BYTES = 'docsync_max_file_bytes';
// There is deliberately no size key here: a document that arrives through a
// binding is bound by the same ceiling as one somebody uploads (MAX_FILE_SIZE in
// files.constants), checked against the announced size and again while the
// bytes stream. A second, sync-only limit would be a number nobody maintains
// and a way for the two paths to disagree about what fits.
@@ -319,6 +319,9 @@ export class DocSyncController {
) {
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
// A person asking for a run is also asking for the rows that gave up to be
// tried once more; the scheduler gets no such reprieve.
this.sync.retryShelvedItems(link.id);
return this.sync.syncLink(link, { full: body.full });
}
+58 -18
View File
@@ -105,6 +105,8 @@ export interface SyncItemState {
pushedSha256: string | null;
state: string;
attempts: number;
/** When the backoff lets this row be tried again; null once it is shelved. */
nextAttemptAt: string | null;
remoteMissingAt: string | null;
}
@@ -156,6 +158,10 @@ export interface ReconcileInput {
remoteTruncated: boolean;
/** Providers without stable ids need the rename heuristic. */
stableRemoteIds: boolean;
/** How many failures a row gets before it is shelved for a person to look at. */
maxAttempts: number;
/** Injected so the backoff window is testable; defaults to now. */
now?: string;
}
/**
@@ -168,9 +174,43 @@ export interface ReconcileInput {
* or ping-pong forever.
*/
export function planReconcile(input: ReconcileInput): ReconcilePlan {
const { items, remote, local, direction, remoteTruncated, stableRemoteIds } = input;
const { items, remote, local, direction, remoteTruncated, stableRemoteIds, maxAttempts } = input;
const now = input.now ?? new Date().toISOString().replace('T', ' ').slice(0, 19);
const actions: PlanAction[] = [];
/**
* Rows that must not be tried again on this run.
*
* A provider that refuses a document refuses it every time — a file it reads
* as corrupt, a type it does not accept, a quota that is full. Without this
* the row is re-planned on every pass, so a single bad document turns into an
* upload attempt every cron tick forever; rows were found at 46 attempts
* against a limit of 6, because the limit was written to the database and
* never read back out.
*
* Shelved is not dead: a manual run clears the counter (see resetItemAttempts),
* which is what "waits for a person" means.
*/
const blocked = new Set<number>();
for (const it of items) {
if (it.state !== 'error') continue;
if (it.attempts >= maxAttempts) { blocked.add(it.id); continue; }
if (it.nextAttemptAt !== null && it.nextAttemptAt > now) blocked.add(it.id);
}
/**
* Every action goes through here so one rule covers all of them.
*
* No exception for `mark_remote_missing`: a row only counts as vanished if it
* was `synced` last run, and a row is only shelved if it is in `error`, so the
* two can never describe the same row.
*/
const add = (action: PlanAction): void => {
const itemId = 'itemId' in action ? action.itemId : null;
if (itemId !== null && blocked.has(itemId)) return;
actions.push(action);
};
const remoteById = new Map<string, RemoteDocument>();
for (const r of remote) if (!r.isDeleted) remoteById.set(r.remoteId, r);
const localById = new Map<number, LocalDocument>();
@@ -213,22 +253,22 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
const localChanged = !!l && l.sha256 !== null && it.contentSha256 !== null && l.sha256 !== it.contentSha256;
if (l?.deletedAt) {
actions.push({ kind: 'local_deleted', itemId: it.id, remoteId: it.remoteId });
add({ kind: 'local_deleted', itemId: it.id, remoteId: it.remoteId });
continue;
}
if (remoteChanged && !isEcho && localChanged) {
actions.push({ kind: 'conflict', itemId: it.id, remote: r, local: l ?? null });
add({ kind: 'conflict', itemId: it.id, remote: r, local: l ?? null });
continue;
}
if (remoteChanged && !isEcho) {
if (direction === 'push') { actions.push({ kind: 'touch', itemId: it.id, remote: r }); continue; }
actions.push({ kind: 'pull_update', remote: r, itemId: it.id });
if (direction === 'push') { add({ kind: 'touch', itemId: it.id, remote: r }); continue; }
add({ kind: 'pull_update', remote: r, itemId: it.id });
continue;
}
if (localChanged && l) {
if (direction === 'pull') { actions.push({ kind: 'touch', itemId: it.id, remote: r }); continue; }
actions.push({ kind: 'push_update', local: l, itemId: it.id, remoteId: it.remoteId });
if (direction === 'pull') { add({ kind: 'touch', itemId: it.id, remote: r }); continue; }
add({ kind: 'push_update', local: l, itemId: it.id, remoteId: it.remoteId });
continue;
}
@@ -242,28 +282,28 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
const localRenamed = it.remoteName !== null && l.name !== it.remoteName;
if (providerRenamed && !localRenamed && direction !== 'push') {
actions.push({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
continue;
}
if (localRenamed && !providerRenamed && direction !== 'pull') {
actions.push({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
add({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
continue;
}
if (providerRenamed && localRenamed) {
// Both sides renamed. Nothing here can pick the right one, and picking
// wrong loses a name somebody chose, so it goes to a person.
actions.push({ kind: 'conflict', itemId: it.id, remote: r, local: l });
add({ kind: 'conflict', itemId: it.id, remote: r, local: l });
continue;
}
// No stored name to arbitrate with (a row from before this column, or a
// freshly paired document): follow the binding's direction.
if (direction === 'pull') actions.push({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
else if (direction === 'push') actions.push({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
else actions.push({ kind: 'touch', itemId: it.id, remote: r });
if (direction === 'pull') add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
else if (direction === 'push') add({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
else add({ kind: 'touch', itemId: it.id, remote: r });
continue;
}
actions.push({ kind: 'touch', itemId: it.id, remote: r });
add({ kind: 'touch', itemId: it.id, remote: r });
}
// ── Upstream only ─────────────────────────────────────────────────────────
@@ -281,11 +321,11 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
(i) => i.contentSha256 === r.contentHash && i.remoteId !== null && !remoteById.has(i.remoteId),
);
if (reuse) {
actions.push({ kind: 'touch', itemId: reuse.id, remote: r });
add({ kind: 'touch', itemId: reuse.id, remote: r });
continue;
}
}
actions.push({ kind: 'pull', remote: r, itemId: null });
add({ kind: 'pull', remote: r, itemId: null });
}
}
@@ -299,7 +339,7 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
// "missing", not re-uploaded — otherwise a broken listing duplicates the
// entire trip upstream.
if (it?.remoteId) continue;
actions.push({ kind: 'push', local: l, itemId: it?.id ?? null, remoteId: null });
add({ kind: 'push', local: l, itemId: it?.id ?? null, remoteId: null });
}
}
@@ -307,7 +347,7 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
if (!guardTripped && !remoteTruncated) {
for (const it of vanished) {
if (it.remoteMissingAt) continue;
actions.push({ kind: 'mark_remote_missing', itemId: it.id });
add({ kind: 'mark_remote_missing', itemId: it.id });
}
}
+63 -2
View File
@@ -162,6 +162,7 @@ export class DocSyncService {
direction: link.direction as 'both' | 'pull' | 'push',
remoteTruncated: listing.data.truncated,
stableRemoteIds: provider.capabilities(ref).stableId,
maxAttempts: ITEM_MAX_ATTEMPTS,
});
if (plan.massDeleteGuardTripped) {
@@ -440,13 +441,32 @@ export class DocSyncService {
return 'pushed';
}
/**
* Put the shelved rows of one binding back in the queue.
*
* A row that has used up its attempts stays put until a person says otherwise,
* and pressing "Sync now" is that person saying otherwise: they have taken the
* bad document out of the folder, cleared the quota, or fixed the permission,
* and the only way to find out is to try again. The scheduler never calls
* this — automatic retries are exactly what the limit exists to stop.
*/
retryShelvedItems(linkId: number): void {
this.db.connection
.prepare(
`UPDATE document_sync_items
SET attempts = 0, next_attempt_at = NULL
WHERE link_id = ? AND state = 'error'`,
)
.run(linkId);
}
// ── State loading and writing ──────────────────────────────────────────────
private loadItems(linkId: number): SyncItemState[] {
const rows = this.db.connection
.prepare(
`SELECT id, file_id, trek_doc_uid, remote_id, remote_version, remote_name, content_sha256,
pushed_sha256, state, attempts, remote_missing_at
pushed_sha256, state, attempts, next_attempt_at, remote_missing_at
FROM document_sync_items WHERE link_id = ?`,
)
.all(linkId) as Array<Record<string, unknown>>;
@@ -461,6 +481,7 @@ export class DocSyncService {
pushedSha256: r.pushed_sha256 === null ? null : String(r.pushed_sha256),
state: String(r.state),
attempts: Number(r.attempts ?? 0),
nextAttemptAt: r.next_attempt_at === null || r.next_attempt_at === undefined ? null : String(r.next_attempt_at),
remoteMissingAt: r.remote_missing_at === null ? null : String(r.remote_missing_at),
}));
}
@@ -694,8 +715,48 @@ export class DocSyncService {
const counts = this.db.connection
.prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state')
.all(tripId) as Array<{ state: string; n: number }>;
/**
* What each side is actually holding, per binding.
*
* The UI showed the last run's transfer counts, which are zero on a binding
* that is already in step — so the interesting screen said nothing. These
* are the standing numbers instead: how many documents this trip has, how
* many of them the store has, and how many are only on one side.
*/
const totalHere = this.db.connection
.prepare(
`SELECT COUNT(*) AS n FROM trip_files
WHERE trip_id = ? AND deleted_at IS NULL AND message_id IS NULL AND note_id IS NULL`,
)
.get(tripId) as { n: number };
const perLink = this.db.connection
.prepare(
`SELECT link_id,
SUM(CASE WHEN file_id IS NOT NULL AND remote_id IS NOT NULL AND state = 'synced' THEN 1 ELSE 0 END) AS paired,
SUM(CASE WHEN remote_id IS NOT NULL AND state != 'remote_missing' THEN 1 ELSE 0 END) AS atProvider,
SUM(CASE WHEN state = 'remote_missing' THEN 1 ELSE 0 END) AS missing
FROM document_sync_items
WHERE trip_id = ?
GROUP BY link_id`,
)
.all(tripId) as Array<{ link_id: number; paired: number; atProvider: number; missing: number }>;
const byLink = new Map(perLink.map((r) => [r.link_id, r]));
return {
links: links.map((l) => this.config.publicLink(l, null)),
links: links.map((l) => {
const row = byLink.get(l.id);
return {
...this.config.publicLink(l, null),
holdings: {
inTrek: totalHere.n,
atProvider: Number(row?.atProvider ?? 0),
paired: Number(row?.paired ?? 0),
missing: Number(row?.missing ?? 0),
},
};
}),
items: Object.fromEntries(counts.map((c) => [c.state, c.n])),
};
}
@@ -63,8 +63,9 @@ const TRASH_FOLDER = '.trek-trash';
/**
* The adapter's own ceiling for one transfer. Not a policy — the install's limit
* lives in `docsync_max_file_bytes` and is enforced by the core — just a bound
* so a wrong path or a truncated header cannot start an unbounded read.
* is the upload limit (`MAX_FILE_SIZE`), enforced by the core before and during
* every transfer — just a bound so a wrong path or a truncated header cannot
* start an unbounded read.
*/
const MAX_TRANSFER_BYTES = 2 * 1024 * 1024 * 1024;
+13 -2
View File
@@ -10,6 +10,7 @@
* (it replaced ADMIN-BR-001 when the old admin bridge died with the cron move).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
import { ADDON_IDS, MCP_GATED_ADDON_IDS } from '../../../src/addons';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -502,9 +503,19 @@ describe('updateAddon', () => {
// real flip of an MCP-relevant addon → invalidate
expect((updateAddon('packing', { enabled: false }) as any).mcpAffected).toBe(true);
expect((updateAddon('packing', { enabled: true }) as any).mcpAffected).toBe(true);
// real flip of an addon with no MCP surface → sessions survive
// real flip of an addon with no MCP surface → sessions survive. Taken from
// the list rather than named, because an addon that grows MCP tools joins it
// and would otherwise turn this assertion false without changing anything
// it is actually about (documents did, when document sync landed).
const noMcp = Object.values(ADDON_IDS).find(id => !MCP_GATED_ADDON_IDS.includes(id));
if (noMcp) {
const flip = updateAddon(noMcp, { enabled: false }) as any;
if (!flip.error) expect(flip.mcpAffected).toBe(false);
}
// and the one this change put on the list carries the opposite verdict
const docsFlip = updateAddon('documents', { enabled: false }) as any;
if (!docsFlip.error) expect(docsFlip.mcpAffected).toBe(false);
if (!docsFlip.error) expect(docsFlip.mcpAffected).toBe(true);
});
it('ADMIN-SVC-087 — refuses to enable a photo provider while journey is off', () => {
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import crypto from 'crypto';
import type { Request } from 'express';
@@ -27,8 +27,11 @@ import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.serv
* always the same, and whether a run was scheduled, which is the only place the
* decision is visible at all.
*
* No timers and no clock: the signature is computed here from a fixed id and
* timestamp, exactly as the provider would.
* The signature is computed here from a fixed id and timestamp, exactly as the
* provider would. Timers are faked, because the endpoint now collects a burst
* before it runs: providers fire once per document, and twenty files dropped
* into a watched folder used to be twenty runs — nineteen of which the service
* answered `busy` and threw away.
*/
const SECRET = 'M7dQ2vLp5rTn8kYw1xZc4bJh';
@@ -60,6 +63,9 @@ const link = (over: Partial<LinkRow> = {}): LinkRow => ({
const config = {
getLinkByToken: vi.fn((token: string) => (token === 'tok-live' ? link() : undefined)),
// Looked up again when the timer fires, so a binding switched off during the
// window does not get one last run out of a stale row.
getLink: vi.fn((id: number) => (id === 4 ? link() : undefined)),
webhookSecret: vi.fn(() => SECRET),
};
@@ -98,10 +104,22 @@ function papraReq(payload: string, signature: string, sent = payload): Request {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
config.getLinkByToken.mockImplementation((token: string) => (token === 'tok-live' ? link() : undefined));
config.getLink.mockImplementation((id: number) => (id === 4 ? link() : undefined));
config.webhookSecret.mockReturnValue(SECRET);
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
/** Let the debounce window pass, so a scheduled run actually happens. */
function settle() {
vi.advanceTimersByTime(6000);
}
describe('an unknown token', () => {
it('answers as if it were known, so nobody can enumerate which tokens exist', () => {
expect(controller.nudge('tok-guessed', makeReq())).toEqual({ received: true });
@@ -124,7 +142,9 @@ describe('a binding whose sync is switched off', () => {
describe('a shared-secret header', () => {
it('triggers the run when it matches', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
settle();
expect(sync.syncLink.mock.calls[0][0]).toMatchObject({ id: 4 });
});
@@ -142,6 +162,7 @@ describe('a shared-secret header', () => {
it('is not demanded from a binding that carries no secret — the token alone authenticates there', () => {
config.webhookSecret.mockReturnValue('');
controller.nudge('tok-live', makeReq());
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
});
@@ -151,6 +172,7 @@ describe('a Papra standard-webhooks signature', () => {
it('triggers the run when it covers the bytes that arrived', () => {
controller.nudge('tok-live', papraReq(payload, sign(payload)));
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
@@ -184,6 +206,7 @@ describe('a Papra standard-webhooks signature', () => {
{ rawBody: Buffer.from(payload, 'utf8') },
);
controller.nudge('tok-live', req);
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
@@ -194,6 +217,7 @@ describe('a Papra standard-webhooks signature', () => {
{ body },
);
controller.nudge('tok-live', req);
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
@@ -224,3 +248,75 @@ describe('a credential of the wrong length', () => {
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
/**
* A burst of calls is one run.
*
* Every provider here fires per document: a folder of twenty files is twenty
* calls within a second or two. Each used to start its own run — the service's
* in-flight guard then answered `busy` to nineteen of them, so nineteen
* announcements were thrown away and the one run that did start had begun
* before most of the changes landed.
*/
describe('a burst of nudges', () => {
it('runs once for twenty calls rather than twenty times', () => {
for (let i = 0; i < 20; i += 1) {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
}
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('answers every one of them immediately, before any run happens', () => {
const answers = [];
for (let i = 0; i < 5; i += 1) {
answers.push(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })));
}
// Paperless gives the call five seconds before it counts it as failed.
expect(answers).toEqual(Array.from({ length: 5 }, () => ({ received: true })));
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('runs again for a burst that arrives after the window closed', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
settle();
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(2);
});
it('keeps one window per binding, so a busy trip cannot starve a quiet one', () => {
const other = link({ id: 9, webhook_token: 'tok-other' });
config.getLinkByToken.mockImplementation((t: string) =>
t === 'tok-live' ? link() : t === 'tok-other' ? other : undefined);
config.getLink.mockImplementation((id: number) => (id === 4 ? link() : id === 9 ? other : undefined));
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
controller.nudge('tok-other', makeReq({ 'x-trek-docsync-secret': SECRET }));
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(2);
expect(sync.syncLink.mock.calls.map((c) => c[0].id).sort()).toEqual([4, 9]);
});
it('drops the run when the binding is switched off inside the window', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
config.getLink.mockReturnValue(link({ sync_enabled: 0 }));
settle();
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('drops the run when the binding is deleted inside the window', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
config.getLink.mockReturnValue(undefined);
settle();
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('forgets its pending timers when the module goes down', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
controller.onModuleDestroy();
settle();
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
@@ -115,6 +115,7 @@ const nextcloud = fakeProvider('nextcloud');
const sync = {
syncLink: vi.fn(async (_link: LinkRow, _opts?: { full?: boolean }) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })),
retryShelvedItems: vi.fn((_linkId: number) => {}),
status: vi.fn(() => ({ links: [], items: {} })),
resolveConflict: vi.fn(async () => true),
};
@@ -665,3 +666,28 @@ describe('the callback origin a provider is given', () => {
expect(link.webhookUrl).toBeNull();
});
});
/**
* A manual run is also a request to try the given-up rows once more.
*
* The scheduler must never do this: the attempt limit exists precisely so a
* document a provider refuses is not re-uploaded on every tick. A person
* pressing the button is the signal that something changed.
*/
describe('a manual run and the shelved rows', () => {
it('clears the attempt counters of the link it runs', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
sync.retryShelvedItems.mockClear();
await controller.syncNow(String(tripId), String(created.id), { full: false });
expect(sync.retryShelvedItems).toHaveBeenCalledWith(created.id);
});
it('does not clear them for a link the caller does not own', async () => {
sync.retryShelvedItems.mockClear();
await expect(controller.syncNow(String(tripId), '999999', { full: false })).rejects.toThrow();
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
});
});
@@ -52,6 +52,7 @@ const item = (over: Partial<SyncItemState> = {}): SyncItemState => ({
pushedSha256: null,
state: 'synced',
attempts: 0,
nextAttemptAt: null,
remoteMissingAt: null,
...over,
});
@@ -64,6 +65,7 @@ const plan = (over: Partial<Parameters<typeof planReconcile>[0]> = {}) =>
direction: 'both',
remoteTruncated: false,
stableRemoteIds: true,
maxAttempts: 6,
...over,
});
@@ -311,3 +313,89 @@ describe('backoffSeconds', () => {
expect(backoffSeconds([60, 300], 0)).toBe(60);
});
});
/**
* The retry limit, read back out.
*
* Found in a live database at 46 attempts against a limit of 6: the counter and
* the backoff were written on every failure and never consulted when planning
* the next run, so a document a provider refuses — a file it reads as corrupt,
* a type it will not take, a full quota — was re-uploaded on every cron tick
* for as long as it existed.
*/
describe('planReconcile > shelving a row that keeps failing', () => {
const failing = (over: Partial<SyncItemState> = {}) =>
item({ id: 20, remoteId: null, remoteVersion: null, remoteName: null, contentSha256: null, state: 'error', ...over });
it('re-plans a failed push while attempts are left', () => {
const p = plan({
items: [failing({ attempts: 2 })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
});
expect(p.actions.map(a => a.kind)).toContain('push');
});
it('stops re-planning once the attempts are used up', () => {
const p = plan({
items: [failing({ attempts: 6 })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
});
expect(p.actions.map(a => a.kind)).not.toContain('push');
});
it('stops re-planning past the limit as well, not only exactly at it', () => {
const p = plan({
items: [failing({ attempts: 46 })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
});
expect(p.actions).toEqual([]);
});
it('holds off while the backoff window is still open', () => {
const p = plan({
items: [failing({ attempts: 1, nextAttemptAt: '2030-01-01 00:00:00' })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
now: '2026-01-01 00:00:00',
});
expect(p.actions).toEqual([]);
});
it('tries again once the backoff window has passed', () => {
const p = plan({
items: [failing({ attempts: 1, nextAttemptAt: '2026-01-01 00:00:00' })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
now: '2026-06-01 00:00:00',
});
expect(p.actions.map(a => a.kind)).toContain('push');
});
it('leaves a healthy row alone however high its old attempt count is', () => {
// attempts is not reset on success, so a row that failed twice and then
// went through must not be mistaken for a shelved one.
const p = plan({
items: [item({ attempts: 9, state: 'synced' })],
remote: [remote({ remoteId: 'r1', remoteVersion: 'v2' })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
});
expect(p.actions.map(a => a.kind)).toContain('pull_update');
});
it('leaves a shelved row alone even when its provider copy is gone too', () => {
// "Vanished" is only said about a row that was synced last run, and a row is
// only shelved while it is in error, so these two never describe the same
// row — nothing is planned for it either way.
const p = plan({
items: [item({ id: 30, attempts: 99, state: 'error', remoteId: 'r-gone' })],
remote: [],
local: [],
maxAttempts: 6,
});
expect(p.actions).toEqual([]);
});
});
+76 -10
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'مزامنة المستندات',
'docsync.subtitle': 'أبقِ مستندات هذه الرحلة متوافقة مع مخزن المستندات الخاص بك.',
'docsync.noProviders': 'لا يوجد مزوّدو مستندات متاحون',
'docsync.noProvidersHint': 'يفعّلها مسؤول الخادم من الإدارة ← الإضافات ← المستندات.',
'docsync.addProvider': 'ربط مزوّد',
'docsync.test': 'اختبار الاتصال',
'docsync.connect.optional': 'اختياري',
'docsync.connected': 'متصل',
'docsync.chooseFolder': 'اختيار مجلد',
'docsync.chooseFolderHint':
'اختر المجلد أو الوسم أو المساحة التي تخص هذه الرحلة. تُزامَن المستندات الموجودة فيها فقط.',
'docsync.noFolders': 'لم يُعثر على شيء على هذا الخادم بعد.',
'docsync.newFolderPlaceholder': 'اسم المجلد الجديد',
'docsync.createFolder': 'إنشاء',
'docsync.syncNow': 'زامن الآن',
'docsync.unlink': 'قطع الاتصال',
'docsync.confirmUnlink': 'تبقى المستندات في TREK وفي المخزن. يزول الاقتران بينهما فقط.',
'docsync.syncEnabled': 'المزامنة تلقائياً',
'docsync.direction': 'الاتجاه',
'docsync.directionBoth': 'في الاتجاهين',
'docsync.directionPull': 'إلى TREK فقط',
'docsync.directionPush': 'إلى المزوّد فقط',
'docsync.deletePolicy': 'عند حذف مستند',
'docsync.deleteUnlink': 'الإبقاء على النسختين وإلغاء الاقتران',
'docsync.deleteTrash': 'نقل النسخة الأخرى إلى سلة المهملات لديها',
'docsync.deleteUnlink': 'الإبقاء على النسختين',
'docsync.deleteTrash': 'النقل إلى سلة المهملات',
'docsync.webhookHint': 'الصق هذا الـ URL في المزوّد لتصل التغييرات فوراً. بدونه يتحقق TREK على فترات زمنية.',
// حقول نموذج الاتصال. تطابق المفاتيح عمود `label` في
@@ -95,6 +89,78 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'اختفت معظم المستندات دفعة واحدة، لذا لم يُغيَّر شيء. تأكد من أن المجلد لا يزال موصولاً.',
'docsync.error.unknown': 'حدث خطأ ما.',
// ── النافذة ────────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'هذه الرحلة',
'docsync.addAnother': 'إضافة آخر',
'docsync.syncing': 'جارٍ المزامنة',
'docsync.card.pickFolder': 'متصل، اختر مجلداً',
'docsync.empty.title': 'لا شيء مرتبط بعد',
'docsync.empty.hintOwner':
'اختر مخزناً من القائمة الجانبية. يحتفظ TREK بنسخته الخاصة من كل شيء، فلا يضيع شيء إذا اختفى المخزن.',
'docsync.empty.hintMember': 'يتولى مالك الرحلة إعداد هذا. تبقى المستندات في TREK في الحالتين.',
// كيف يرتّب كل منتج الملفات. يظهر قبل أي ربط، لأنه ما ستطلبه الشاشة التالية.
'docsync.model.paperless': 'التصنيف بالوسوم',
'docsync.model.papra': 'التصنيف بالوسوم داخل مؤسسة',
'docsync.model.nextcloud': 'التصنيف في مجلد',
'docsync.model.opencloud': 'التصنيف في مساحة',
'docsync.model.synologydrive': 'التصنيف في مجلد على جهاز NAS',
// ── شريط التدفق ────────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'إلى المخزن',
'docsync.flow.toTrek': 'من المخزن',
'docsync.flow.documents': 'مستندات',
'docsync.flow.summary.both': 'المستندات تنتقل في الاتجاهين.',
'docsync.flow.summary.pull': 'المستندات تدخل فقط.',
'docsync.flow.summary.push': 'المستندات تخرج فقط.',
'docsync.flow.summaryEditable.both': 'تنتقل في الاتجاهين. انقر مساراً لإيقافه.',
'docsync.flow.summaryEditable.pull': 'تدخل فقط. انقر المسار الآخر لإرسالها أيضاً.',
'docsync.flow.summaryEditable.push': 'تخرج فقط. انقر المسار الآخر لاستقبالها أيضاً.',
// ── اقتران واحد ────────────────────────────────────────────────────────────
'docsync.binding.settings': 'الإعدادات',
'docsync.binding.folder': 'المجلد',
'docsync.binding.lastRun': 'آخر تشغيل',
'docsync.binding.autoOff': 'متوقفة مؤقتاً',
'docsync.binding.neverRun': 'لم تُشغَّل بعد',
'docsync.binding.deleteHint': 'ما الذي يحدث للنسخة في الجهة الأخرى.',
'docsync.binding.autoHint': 'التحقق من التغييرات في الخلفية.',
'docsync.binding.webhookTitle': 'تحديثات فورية',
'docsync.binding.copy': 'نسخ',
'docsync.binding.copied': 'تم النسخ',
// ── الربط ──────────────────────────────────────────────────────────────────
'docsync.connect.submit': 'ربط',
'docsync.connect.testing': 'جارٍ محاولة الوصول',
'docsync.connect.okAs': 'تم الوصول، وسُجّل الدخول باسم {account}',
'docsync.connect.insecureHint': 'لخادم على شبكتك الخاصة بشهادة موقّعة ذاتياً.',
'docsync.connect.about.paperless': 'يحفظ TREK هذه الرحلة تحت وسم خاص بها ولا يمسّ بقية أرشيفك.',
'docsync.connect.about.papra': 'اختر المؤسسة التي تنتمي إليها هذه الرحلة. يحفظها TREK داخلها تحت وسم خاص بها.',
'docsync.connect.about.nextcloud':
'استخدم كلمة مرور تطبيق لا كلمة مرور حسابك: فهي تعمل مع المصادقة الثنائية ويمكنك إبطالها وحدها.',
'docsync.connect.about.opencloud': 'يحصل TREK على مساحة خاصة بهذه الرحلة، منفصلة عن كل ما عداها.',
'docsync.connect.about.synologydrive': 'يُفضّل حساب DSM لا يصل إلا إلى المجلد المشترك المخصّص لهذه الرحلة.',
// ── اختيار الحاوية ─────────────────────────────────────────────────────────
'docsync.scope.title': 'أين تُحفظ هذه الرحلة في {provider}؟',
'docsync.scope.intro': 'يُزامَن ما بداخله فقط. كل ما عداه في مخزنك يبقى خارج TREK.',
'docsync.scope.createTitle': 'إنشاء واحد جديد',
'docsync.scope.createAction': 'إنشاء',
'docsync.scope.pickTitle': 'أو استخدم واحداً موجوداً لديك',
'docsync.scope.search': 'بحث',
'docsync.scope.noMatch': 'لا شيء يطابق ذلك.',
// ── أمور تحتاج قراراً من المستخدم ──────────────────────────────────────────
'docsync.issues.title': 'يحتاج إلى مراجعة',
'docsync.issues.conflict': 'تغيّر في الجهتين. اختر النسخة التي تبقى.',
'docsync.issues.remote_missing': 'اختفى من المخزن. نسخة TREK لا تزال هنا.',
'docsync.issues.rejected_type': 'نوع الملف هذا غير مسموح به هنا.',
'docsync.issues.too_large': 'أكبر من الحد المسموح.',
'docsync.issues.error': 'لم يتم النقل.',
'docsync.error.unknown_provider': 'هذا المزوّد غير متاح على هذا الخادم.',
};
+79 -10
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Sincronização de documentos',
'docsync.subtitle': 'Mantenha os documentos desta viagem em sintonia com o seu próprio repositório de documentos.',
'docsync.noProviders': 'Nenhum provedor de documentos está disponível',
'docsync.noProvidersHint': 'Um administrador da instância ativa isso em Admin, Complementos, Documentos.',
'docsync.addProvider': 'Conectar um provedor',
'docsync.test': 'Testar conexão',
'docsync.connect.optional': 'Opcional',
'docsync.connected': 'Conectado',
'docsync.chooseFolder': 'Escolher pasta',
'docsync.chooseFolderHint':
'Escolha a pasta, a etiqueta ou o espaço que pertence a esta viagem. Só os documentos que estão nele são sincronizados.',
'docsync.noFolders': 'Nada encontrado nesta instância ainda.',
'docsync.newFolderPlaceholder': 'Nome da nova pasta',
'docsync.createFolder': 'Criar',
'docsync.syncNow': 'Sincronizar agora',
'docsync.unlink': 'Desconectar',
'docsync.confirmUnlink': 'Os documentos continuam no TREK e no repositório. Só o vínculo entre eles acaba.',
'docsync.syncEnabled': 'Sincronizar automaticamente',
'docsync.direction': 'Direção',
'docsync.directionBoth': 'Nos dois sentidos',
'docsync.directionPull': 'Somente para o TREK',
'docsync.directionPush': 'Somente para o provedor',
'docsync.deletePolicy': 'Quando um documento é excluído',
'docsync.deleteUnlink': 'Manter as duas cópias e desfazer o vínculo',
'docsync.deleteTrash': 'Mover a outra cópia para a lixeira dela',
'docsync.deleteUnlink': 'Manter as duas cópias',
'docsync.deleteTrash': 'Mover para a lixeira',
'docsync.webhookHint':
'Cole esta URL no seu provedor para que as mudanças cheguem na hora. Sem isso, o TREK verifica em intervalos.',
@@ -99,6 +93,81 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Quase todos os documentos sumiram de uma vez, então nada foi alterado. Verifique se a pasta ainda está montada.',
'docsync.error.unknown': 'Algo deu errado.',
// ── A janela ───────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Esta viagem',
'docsync.addAnother': 'Adicionar outro',
'docsync.syncing': 'Sincronizando',
'docsync.card.pickFolder': 'Conectado, escolha uma pasta',
'docsync.empty.title': 'Nada conectado ainda',
'docsync.empty.hintOwner':
'Escolha um repositório à esquerda. O TREK guarda a própria cópia de tudo, então nada se perde se ele sumir.',
'docsync.empty.hintMember': 'Quem é dono da viagem configura isso. Os documentos ficam no TREK de todo jeito.',
// Como cada produto organiza as coisas. Aparece antes de alguém conectar,
// porque é o que a próxima tela vai pedir.
'docsync.model.paperless': 'Arquiva por etiqueta',
'docsync.model.papra': 'Arquiva por etiqueta, dentro de uma organização',
'docsync.model.nextcloud': 'Arquiva em uma pasta',
'docsync.model.opencloud': 'Arquiva em um espaço',
'docsync.model.synologydrive': 'Arquiva em uma pasta no NAS',
// ── A barra de fluxo ───────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Saída para o repositório',
'docsync.flow.toTrek': 'Entrada do repositório',
'docsync.flow.documents': 'documentos',
'docsync.flow.summary.both': 'Os documentos vão e voltam.',
'docsync.flow.summary.pull': 'Os documentos só entram.',
'docsync.flow.summary.push': 'Os documentos só saem.',
'docsync.flow.summaryEditable.both': 'Indo e voltando. Toque em uma faixa para parar.',
'docsync.flow.summaryEditable.pull': 'Só entrando. Toque na outra faixa para enviar também.',
'docsync.flow.summaryEditable.push': 'Só saindo. Toque na outra faixa para receber também.',
// ── Um vínculo ─────────────────────────────────────────────────────────────
'docsync.binding.settings': 'Configurações',
'docsync.binding.folder': 'Pasta',
'docsync.binding.lastRun': 'Última execução',
'docsync.binding.autoOff': 'Pausado',
'docsync.binding.neverRun': 'ainda não executado',
'docsync.binding.deleteHint': 'O que acontece com a cópia do outro lado.',
'docsync.binding.autoHint': 'Procurar mudanças em segundo plano.',
'docsync.binding.webhookTitle': 'Atualizações na hora',
'docsync.binding.copy': 'Copiar',
'docsync.binding.copied': 'Copiado',
// ── Conectando ─────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Conectar',
'docsync.connect.testing': 'Tentando acessar',
'docsync.connect.okAs': 'Acessado, conectado como {account}',
'docsync.connect.insecureHint': 'Para uma instância na sua própria rede com certificado autoassinado.',
'docsync.connect.about.paperless': 'O TREK arquiva esta viagem na etiqueta dela e nunca mexe no resto do seu acervo.',
'docsync.connect.about.papra':
'Escolha a organização a que esta viagem pertence. O TREK a arquiva na etiqueta dela dentro dessa organização.',
'docsync.connect.about.nextcloud':
'Use uma senha de aplicativo, não a senha da sua conta: ela sobrevive ao dois fatores e pode ser revogada sozinha.',
'docsync.connect.about.opencloud': 'O TREK ganha um espaço próprio para esta viagem, separado de todo o resto.',
'docsync.connect.about.synologydrive':
'De preferência uma conta DSM que alcance só a pasta compartilhada que esta viagem deve usar.',
// ── Escolhendo o destino ───────────────────────────────────────────────────
'docsync.scope.title': 'Onde esta viagem deve ficar no {provider}?',
'docsync.scope.intro': 'Só o que está aqui dentro é sincronizado. Todo o resto do seu repositório fica fora do TREK.',
'docsync.scope.createTitle': 'Criar um novo',
'docsync.scope.createAction': 'Criar',
'docsync.scope.pickTitle': 'Ou use um que você já tem',
'docsync.scope.search': 'Buscar',
'docsync.scope.noMatch': 'Nada corresponde a isso.',
// ── Coisas que alguém precisa decidir ──────────────────────────────────────
'docsync.issues.title': 'Precisa de atenção',
'docsync.issues.conflict': 'Mudou nos dois lugares. Escolha qual manter.',
'docsync.issues.remote_missing': 'Sumiu do repositório. A cópia do TREK continua aqui.',
'docsync.issues.rejected_type': 'Este tipo de arquivo não é permitido aqui.',
'docsync.issues.too_large': 'Maior que o limite.',
'docsync.issues.error': 'A transferência não foi concluída.',
'docsync.error.unknown_provider': 'Este provedor não está disponível nesta instância.',
};
+81 -10
View File
@@ -2,28 +2,23 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Sincronització de documents',
'docsync.subtitle': 'Mantén els documents d’aquest viatge en sintonia amb el teu propi gestor de documents.',
'docsync.noProviders': 'No hi ha cap proveïdor de documents disponible',
'docsync.noProvidersHint': 'Un administrador de la instància els activa a Administració, Complements, Documents.',
'docsync.addProvider': 'Connecta un proveïdor',
'docsync.test': 'Prova la connexió',
'docsync.connect.optional': 'Opcional',
'docsync.connected': 'Connectat',
'docsync.chooseFolder': 'Tria una carpeta',
'docsync.chooseFolderHint':
'Tria la carpeta, l’etiqueta o l’espai que pertany a aquest viatge. Només se’n sincronitzen els documents que hi ha a dins.',
'docsync.noFolders': 'Encara no s’ha trobat res en aquesta instància.',
'docsync.newFolderPlaceholder': 'Nom de la carpeta nova',
'docsync.createFolder': 'Crea',
'docsync.syncNow': 'Sincronitza ara',
'docsync.unlink': 'Desconnecta',
'docsync.confirmUnlink':
'Els documents es queden a TREK i al teu gestor. Només desapareix l’aparellament entre els dos.',
'docsync.syncEnabled': 'Sincronitza automàticament',
'docsync.direction': 'Direcció',
'docsync.directionBoth': 'En tots dos sentits',
'docsync.directionPull': 'Només cap a TREK',
'docsync.directionPush': 'Només cap al proveïdor',
'docsync.deletePolicy': 'Quan s’elimina un document',
'docsync.deleteUnlink': 'Conserva les dues còpies i desfés l’aparellament',
'docsync.deleteTrash': 'Mou l’altra còpia a la seva paperera',
'docsync.deleteUnlink': 'Conserva les dues còpies',
'docsync.deleteTrash': 'Mou a la paperera',
'docsync.webhookHint':
'Enganxa aquesta URL al teu proveïdor perquè els canvis arribin de seguida. Sense això, TREK ho comprova cada cert temps.',
@@ -99,6 +94,82 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Han desaparegut molts documents de cop, així que no s’ha canviat res. Comprova que la carpeta encara estigui muntada.',
'docsync.error.unknown': 'Alguna cosa ha anat malament.',
// ── El diàleg ──────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Aquest viatge',
'docsync.addAnother': 'Afegeix-ne un altre',
'docsync.syncing': 'Sincronitzant',
'docsync.card.pickFolder': 'Connectat, tria una carpeta',
'docsync.empty.title': 'Encara no hi ha res connectat',
'docsync.empty.hintOwner':
'Tria un gestor a l’esquerra. TREK en guarda sempre una còpia pròpia, així que no es perd res si desapareix.',
'docsync.empty.hintMember': 'Això ho configura el propietari del viatge. Els documents es queden a TREK igualment.',
// Com arxiva les coses cada producte. Es mostra abans que ningú connecti res,
// perquè és el que demanarà la pantalla següent.
'docsync.model.paperless': 'Arxiva per etiqueta',
'docsync.model.papra': 'Arxiva per etiqueta, dins d’una organització',
'docsync.model.nextcloud': 'Arxiva en una carpeta',
'docsync.model.opencloud': 'Arxiva en un espai',
'docsync.model.synologydrive': 'Arxiva en una carpeta del NAS',
// ── La barra de flux ───────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Cap al gestor',
'docsync.flow.toTrek': 'Des del gestor',
'docsync.flow.documents': 'documents',
'docsync.flow.summary.both': 'Els documents van en tots dos sentits.',
'docsync.flow.summary.pull': 'Els documents només entren.',
'docsync.flow.summary.push': 'Els documents només surten.',
'docsync.flow.summaryEditable.both': 'Van en tots dos sentits. Toca un carril per aturar-lo.',
'docsync.flow.summaryEditable.pull': 'Només entren. Toca l’altre carril per enviar-ne també.',
'docsync.flow.summaryEditable.push': 'Només surten. Toca l’altre carril per rebre’n també.',
// ── Un aparellament ────────────────────────────────────────────────────────
'docsync.binding.settings': 'Configuració',
'docsync.binding.folder': 'Carpeta',
'docsync.binding.lastRun': 'Última execució',
'docsync.binding.autoOff': 'En pausa',
'docsync.binding.neverRun': 'encara no s’ha executat',
'docsync.binding.deleteHint': 'Què passa amb la còpia de l’altra banda.',
'docsync.binding.autoHint': 'Comprova si hi ha canvis en segon pla.',
'docsync.binding.webhookTitle': 'Actualitzacions instantànies',
'docsync.binding.copy': 'Copia',
'docsync.binding.copied': 'Copiat',
// ── La connexió ────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Connecta',
'docsync.connect.testing': 'Provant de contactar-hi',
'docsync.connect.okAs': 'Contactat, sessió iniciada com a {account}',
'docsync.connect.insecureHint': 'Per a una instància de la teva xarxa amb un certificat autosignat.',
'docsync.connect.about.paperless':
'TREK arxiva aquest viatge sota una etiqueta pròpia i no toca mai la resta del teu arxiu.',
'docsync.connect.about.papra':
'Tria l’organització a què pertany aquest viatge. TREK l’arxiva sota una etiqueta pròpia a dins.',
'docsync.connect.about.nextcloud':
'Fes servir una contrasenya d’aplicació, no la del compte: funciona amb el doble factor i la pots revocar per separat.',
'docsync.connect.about.opencloud': 'TREK té un espai propi per a aquest viatge, separat de tota la resta.',
'docsync.connect.about.synologydrive':
'Millor un compte DSM que només arribi a la carpeta compartida que ha de fer servir aquest viatge.',
// ── Tria del contenidor ────────────────────────────────────────────────────
'docsync.scope.title': 'On ha d’anar aquest viatge dins de {provider}?',
'docsync.scope.intro': 'Només se sincronitza el que hi ha aquí dins. La resta del teu gestor es queda fora de TREK.',
'docsync.scope.createTitle': 'Crea’n un de nou',
'docsync.scope.createAction': 'Crea',
'docsync.scope.pickTitle': 'O fes servir un que ja tinguis',
'docsync.scope.search': 'Cerca',
'docsync.scope.noMatch': 'No hi coincideix res.',
// ── Coses que ha de decidir una persona ────────────────────────────────────
'docsync.issues.title': 'Cal revisar-ho',
'docsync.issues.conflict': 'Ha canviat als dos llocs. Tria quina còpia vols conservar.',
'docsync.issues.remote_missing': 'Ha desaparegut del gestor. La còpia de TREK encara hi és.',
'docsync.issues.rejected_type': 'Aquest tipus de fitxer no s’admet aquí.',
'docsync.issues.too_large': 'Més gran que el límit.',
'docsync.issues.error': 'La transferència no s’ha completat.',
'docsync.error.unknown_provider': 'Aquest proveïdor no està disponible en aquesta instància.',
};
+80 -12
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Synchronizace dokumentů',
'docsync.subtitle': 'Udržujte dokumenty této cesty v souladu s vlastním úložištěm dokumentů.',
'docsync.noProviders': 'Nejsou dostupní žádní poskytovatelé dokumentů',
'docsync.noProvidersHint': 'Správce instance je zapíná v Administrace → Doplňky → Dokumenty.',
'docsync.noProvidersHint': 'Správce instance je zapíná v Administraci, Doplňky, Dokumenty.',
'docsync.addProvider': 'Připojit poskytovatele',
'docsync.test': 'Otestovat připojení',
'docsync.connect.optional': 'Volitelné',
'docsync.connected': 'Připojeno',
'docsync.chooseFolder': 'Vybrat složku',
'docsync.chooseFolderHint':
'Zvolte složku, štítek nebo prostor, který patří k této cestě. Synchronizují se jen dokumenty v něm.',
'docsync.noFolders': 'Na této instanci zatím nic nenalezeno.',
'docsync.newFolderPlaceholder': 'Název nové složky',
'docsync.createFolder': 'Vytvořit',
'docsync.syncNow': 'Synchronizovat teď',
'docsync.unlink': 'Odpojit',
'docsync.confirmUnlink': 'Dokumenty zůstanou v TREKu i v úložišti. Zruší se jen jejich propojení.',
'docsync.syncEnabled': 'Synchronizovat automaticky',
'docsync.direction': 'Směr',
'docsync.directionBoth': 'Oběma směry',
'docsync.directionPull': 'Jen do TREKu',
'docsync.directionPush': 'Jen k poskytovateli',
'docsync.deletePolicy': 'Když se dokument smaže',
'docsync.deleteUnlink': 'Zachovat obě kopie, zrušit propojení',
'docsync.deleteTrash': 'Přesunout druhou kopii do jejího koše',
'docsync.deleteUnlink': 'Zachovat obě kopie',
'docsync.deleteTrash': 'Přesunout do koše',
'docsync.webhookHint':
'Vložte tuto URL k poskytovateli, aby změny přicházely okamžitě. Bez toho se TREK ptá v pravidelných intervalech.',
@@ -46,7 +40,7 @@ const docsync: TranslationStrings = {
'Vytvořte jej v aplikaci Papra pod API keys. Klíče Papra vždy dosáhnou na všechny organizace, do kterých patříte.',
'docsync.hintPapraOrg': 'ID ve tvaru org_… z adresního řádku aplikace Papra.',
'docsync.hintNextcloudLogin': 'Vaše přihlašovací jméno v Nextcloudu, ne e-mailová adresa.',
'docsync.hintNextcloudAppPassword': 'Nastavení → Zabezpečení → Vytvořit nové heslo aplikace. Nikdy heslo k účtu.',
'docsync.hintNextcloudAppPassword': 'Nastavení, Zabezpečení, Vytvořit nové heslo aplikace. Nikdy heslo k účtu.',
'docsync.hintOpenCloudToken': 'Vytváří se v OpenCloudu pod tokeny aplikací.',
'docsync.hintBasePath': 'Kde TREK hledá složky cest. Výchozí je /TREK.',
'docsync.hintSynologyUrl': 'Uveďte i port, například https://nas.example.com:5001',
@@ -97,6 +91,80 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Najednou zmizela většina dokumentů, proto se nic nezměnilo. Zkontrolujte, zda je složka stále připojená.',
'docsync.error.unknown': 'Něco se pokazilo.',
// ── Dialog ─────────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Tato cesta',
'docsync.addAnother': 'Přidat další',
'docsync.syncing': 'Synchronizuje se',
'docsync.card.pickFolder': 'Připojeno, vyberte složku',
'docsync.empty.title': 'Zatím nic nepřipojeno',
'docsync.empty.hintOwner':
'Vyberte úložiště vlevo. TREK si vždy nechává vlastní kopii, takže se nic neztratí, ani když úložiště zmizí.',
'docsync.empty.hintMember': 'Nastavuje to vlastník cesty. Dokumenty tak jako tak zůstávají v TREKu.',
// Jak každý produkt věci ukládá. Zobrazuje se ještě před připojením, protože
// právě na to se zeptá další obrazovka.
'docsync.model.paperless': 'Ukládá podle štítků',
'docsync.model.papra': 'Ukládá podle štítků v rámci organizace',
'docsync.model.nextcloud': 'Ukládá do složky',
'docsync.model.opencloud': 'Ukládá do prostoru',
'docsync.model.synologydrive': 'Ukládá do složky na NAS',
// ── Pruh toku ──────────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Do úložiště',
'docsync.flow.toTrek': 'Z úložiště',
'docsync.flow.documents': 'dokumentů',
'docsync.flow.summary.both': 'Dokumenty putují oběma směry.',
'docsync.flow.summary.pull': 'Dokumenty jen přicházejí.',
'docsync.flow.summary.push': 'Dokumenty jen odcházejí.',
'docsync.flow.summaryEditable.both': 'Oběma směry. Klepnutím na pruh jeden směr vypnete.',
'docsync.flow.summaryEditable.pull': 'Jen dovnitř. Klepnutím na druhý pruh zapnete i odesílání.',
'docsync.flow.summaryEditable.push': 'Jen ven. Klepnutím na druhý pruh zapnete i příjem.',
// ── Jedno propojení ────────────────────────────────────────────────────────
'docsync.binding.settings': 'Nastavení',
'docsync.binding.folder': 'Složka',
'docsync.binding.lastRun': 'Poslední běh',
'docsync.binding.autoOff': 'Pozastaveno',
'docsync.binding.neverRun': 'zatím neproběhlo',
'docsync.binding.deleteHint': 'Co se stane s kopií na druhé straně.',
'docsync.binding.autoHint': 'Kontrolovat změny na pozadí.',
'docsync.binding.webhookTitle': 'Okamžité aktualizace',
'docsync.binding.copy': 'Kopírovat',
'docsync.binding.copied': 'Zkopírováno',
// ── Připojování ────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Připojit',
'docsync.connect.testing': 'Navazuje se spojení',
'docsync.connect.okAs': 'Spojení navázáno, přihlášeno jako {account}',
'docsync.connect.insecureHint': 'Pro instanci ve vlastní síti s vlastnoručně podepsaným certifikátem.',
'docsync.connect.about.paperless': 'TREK ukládá tuto cestu pod vlastním štítkem a zbytku vašeho archivu se nedotkne.',
'docsync.connect.about.papra': 'Vyberte organizaci, do které cesta patří. TREK ji v ní uloží pod vlastním štítkem.',
'docsync.connect.about.nextcloud':
'Použijte heslo aplikace, ne heslo k účtu: funguje i s dvoufaktorovým ověřením a dá se zrušit samostatně.',
'docsync.connect.about.opencloud': 'TREK dostane pro tuto cestu vlastní prostor, oddělený od všeho ostatního.',
'docsync.connect.about.synologydrive':
'Nejlépe účet DSM, který dosáhne jen na sdílenou složku určenou pro tuto cestu.',
// ── Výběr umístění ─────────────────────────────────────────────────────────
'docsync.scope.title': 'Kam v {provider} tuto cestu uložit?',
'docsync.scope.intro': 'Synchronizuje se jen to, co je uvnitř. Zbytek vašeho úložiště zůstane mimo TREK.',
'docsync.scope.createTitle': 'Vytvořit nové',
'docsync.scope.createAction': 'Vytvořit',
'docsync.scope.pickTitle': 'Nebo použijte existující',
'docsync.scope.search': 'Hledat',
'docsync.scope.noMatch': 'Nic neodpovídá.',
// ── Co musí rozhodnout člověk ──────────────────────────────────────────────
'docsync.issues.title': 'Vyžaduje pozornost',
'docsync.issues.conflict': 'Změněno na obou stranách. Vyberte, kterou verzi ponechat.',
'docsync.issues.remote_missing': 'V úložišti už není. Kopie v TREKu tu zůstává.',
'docsync.issues.rejected_type': 'Tento typ souboru zde není povolen.',
'docsync.issues.too_large': 'Větší než povolený limit.',
'docsync.issues.error': 'Přenos neproběhl.',
'docsync.error.unknown_provider': 'Tento poskytovatel není na této instanci dostupný.',
};
+81 -10
View File
@@ -2,29 +2,23 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Dokumentenabgleich',
'docsync.subtitle': 'Halte die Dokumente dieser Reise und deinen eigenen Dokumentenspeicher im Gleichstand.',
'docsync.noProviders': 'Es sind keine Dokumentenanbieter verfügbar',
'docsync.noProvidersHint':
'Ein Administrator dieser Instanz schaltet sie unter Administration, Addons, Dokumente frei.',
'docsync.addProvider': 'Anbieter verbinden',
'docsync.test': 'Verbindung testen',
'docsync.connect.optional': 'Optional',
'docsync.connected': 'Verbunden',
'docsync.chooseFolder': 'Ordner wählen',
'docsync.chooseFolderHint':
'Wähle den Ordner, das Tag oder den Bereich, der zu dieser Reise gehört. Abgeglichen wird nur, was darin liegt.',
'docsync.noFolders': 'Auf dieser Instanz wurde noch nichts gefunden.',
'docsync.newFolderPlaceholder': 'Name des neuen Ordners',
'docsync.createFolder': 'Anlegen',
'docsync.syncNow': 'Jetzt abgleichen',
'docsync.unlink': 'Trennen',
'docsync.confirmUnlink': 'Die Dokumente bleiben in TREK und im Speicher. Nur die Verknüpfung dazwischen fällt weg.',
'docsync.syncEnabled': 'Automatisch abgleichen',
'docsync.direction': 'Richtung',
'docsync.directionBoth': 'In beide Richtungen',
'docsync.directionPull': 'Nur nach TREK',
'docsync.directionPush': 'Nur zum Anbieter',
'docsync.deletePolicy': 'Wenn ein Dokument gelöscht wird',
'docsync.deleteUnlink': 'Beide Kopien behalten, die Verknüpfung lösen',
'docsync.deleteTrash': 'Die andere Kopie in den Papierkorb verschieben',
'docsync.deleteUnlink': 'Beide Kopien behalten',
'docsync.deleteTrash': 'In den Papierkorb verschieben',
'docsync.webhookHint':
'Trage diese URL bei deinem Anbieter ein, damit Änderungen sofort ankommen. Ohne sie fragt TREK in festen Abständen nach.',
@@ -99,6 +93,83 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Es sind auf einen Schlag fast alle Dokumente verschwunden, deshalb wurde nichts geändert. Prüfe, ob der Ordner noch eingebunden ist.',
'docsync.error.unknown': 'Da ist etwas schiefgelaufen.',
// ── Der Dialog ─────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Diese Reise',
'docsync.addAnother': 'Weiteren hinzufügen',
'docsync.syncing': 'Wird abgeglichen',
'docsync.card.pickFolder': 'Verbunden, jetzt Ordner wählen',
'docsync.empty.title': 'Noch nichts verbunden',
'docsync.empty.hintOwner':
'Wähle links einen Speicher. TREK behält von allem eine eigene Kopie, es geht also nichts verloren, wenn er wegfällt.',
'docsync.empty.hintMember': 'Das richtet der Besitzer der Reise ein. Die Dokumente bleiben so oder so in TREK.',
// Wie das jeweilige Produkt ablegt. Steht vor dem Verbinden da, weil der
// nächste Schritt genau danach fragt.
'docsync.model.paperless': 'Legt nach Tag ab',
'docsync.model.papra': 'Legt nach Tag ab, innerhalb einer Organisation',
'docsync.model.nextcloud': 'Legt in einem Ordner ab',
'docsync.model.opencloud': 'Legt in einem Space ab',
'docsync.model.synologydrive': 'Legt in einem Ordner auf dem NAS ab',
// ── Die Flussleiste ────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Raus zum Speicher',
'docsync.flow.toTrek': 'Rein vom Speicher',
'docsync.flow.documents': 'Dokumente',
'docsync.flow.summary.both': 'Dokumente gehen in beide Richtungen.',
'docsync.flow.summary.pull': 'Dokumente kommen nur herein.',
'docsync.flow.summary.push': 'Dokumente gehen nur hinaus.',
'docsync.flow.summaryEditable.both': 'Beide Richtungen. Spur antippen, um sie zu stoppen.',
'docsync.flow.summaryEditable.pull': 'Kommt nur herein. Andere Spur antippen, um auch zu senden.',
'docsync.flow.summaryEditable.push': 'Geht nur hinaus. Andere Spur antippen, um auch zu empfangen.',
// ── Eine Verknüpfung ───────────────────────────────────────────────────────
'docsync.binding.settings': 'Einstellungen',
'docsync.binding.folder': 'Ordner',
'docsync.binding.lastRun': 'Zuletzt gelaufen',
'docsync.binding.autoOff': 'Pausiert',
'docsync.binding.neverRun': 'noch nie gelaufen',
'docsync.binding.deleteHint': 'Was mit der Kopie auf der anderen Seite geschieht.',
'docsync.binding.autoHint': 'Im Hintergrund nach Änderungen suchen.',
'docsync.binding.webhookTitle': 'Sofortige Updates',
'docsync.binding.copy': 'Kopieren',
'docsync.binding.copied': 'Kopiert',
// ── Verbinden ──────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Verbinden',
'docsync.connect.testing': 'Verbindung wird versucht',
'docsync.connect.okAs': 'Erreicht, angemeldet als {account}',
'docsync.connect.insecureHint': 'Für eine Instanz im eigenen Netz mit selbstsigniertem Zertifikat.',
'docsync.connect.about.paperless':
'TREK legt diese Reise unter einem eigenen Tag ab und rührt den Rest deines Archivs nicht an.',
'docsync.connect.about.papra':
'Wähle die Organisation, zu der diese Reise gehört. TREK legt sie darin unter einem eigenen Tag ab.',
'docsync.connect.about.nextcloud':
'Nimm ein App-Passwort, nicht dein Kontopasswort: Es übersteht Zwei-Faktor und lässt sich einzeln widerrufen.',
'docsync.connect.about.opencloud': 'TREK bekommt für diese Reise einen eigenen Space, getrennt von allem anderen.',
'docsync.connect.about.synologydrive':
'Am besten ein DSM-Konto, das nur an den freigegebenen Ordner dieser Reise kommt.',
// ── Den Ablageort wählen ───────────────────────────────────────────────────
'docsync.scope.title': 'Wo soll diese Reise in {provider} liegen?',
'docsync.scope.intro':
'Abgeglichen wird nur, was hier drin liegt. Alles andere in deinem Speicher bleibt außerhalb von TREK.',
'docsync.scope.createTitle': 'Neu anlegen',
'docsync.scope.createAction': 'Anlegen',
'docsync.scope.pickTitle': 'Oder etwas Vorhandenes nehmen',
'docsync.scope.search': 'Suchen',
'docsync.scope.noMatch': 'Dazu passt nichts.',
// ── Was jemand entscheiden muss ────────────────────────────────────────────
'docsync.issues.title': 'Braucht einen Blick',
'docsync.issues.conflict': 'An beiden Stellen geändert. Wähle, welche Fassung bleibt.',
'docsync.issues.remote_missing': 'Im Speicher nicht mehr da. Die Kopie in TREK ist noch hier.',
'docsync.issues.rejected_type': 'Dieser Dateityp ist hier nicht erlaubt.',
'docsync.issues.too_large': 'Größer als das Limit.',
'docsync.issues.error': 'Die Übertragung ist nicht durchgegangen.',
'docsync.error.unknown_provider': 'Dieser Anbieter steht auf dieser Instanz nicht zur Verfügung.',
};
+79 -10
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Document sync',
'docsync.subtitle': "Keep this trip's documents in step with your own document store.",
'docsync.noProviders': 'No document providers are available',
'docsync.noProvidersHint': 'An instance administrator switches these on under Admin, Addons, Documents.',
'docsync.addProvider': 'Connect a provider',
'docsync.test': 'Test connection',
'docsync.connect.optional': 'Optional',
'docsync.connected': 'Connected',
'docsync.chooseFolder': 'Choose folder',
'docsync.chooseFolderHint':
'Pick the folder, tag or space that belongs to this trip. Only documents in it are synced.',
'docsync.noFolders': 'Nothing found on this instance yet.',
'docsync.newFolderPlaceholder': 'New folder name',
'docsync.createFolder': 'Create',
'docsync.syncNow': 'Sync now',
'docsync.unlink': 'Disconnect',
'docsync.confirmUnlink': 'Documents stay in TREK and at the store. Only the pairing between them goes.',
'docsync.syncEnabled': 'Sync automatically',
'docsync.direction': 'Direction',
'docsync.directionBoth': 'Both ways',
'docsync.directionPull': 'Only into TREK',
'docsync.directionPush': 'Only out to the provider',
'docsync.deletePolicy': 'When a document is deleted',
'docsync.deleteUnlink': 'Keep both copies, drop the pairing',
'docsync.deleteTrash': 'Move the other copy to its recycle bin',
'docsync.deleteUnlink': 'Keep both copies',
'docsync.deleteTrash': 'Move to recycle bin',
'docsync.webhookHint':
'Paste this URL into your provider so changes arrive immediately. Without it, TREK checks on a timer.',
@@ -97,6 +91,81 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Most documents vanished at once, so nothing was changed. Check that the folder is still mounted.',
'docsync.error.unknown': 'Something went wrong.',
// ── The dialog ─────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'This trip',
'docsync.addAnother': 'Add another',
'docsync.syncing': 'Syncing',
'docsync.card.pickFolder': 'Connected, pick a folder',
'docsync.empty.title': 'Nothing connected yet',
'docsync.empty.hintOwner':
'Pick a store on the left. TREK keeps its own copy of everything, so nothing is lost if it goes away.',
'docsync.empty.hintMember': 'The trip owner sets this up. Documents stay in TREK either way.',
// How each product files things. Shown before anyone connects, because it is
// what the next screen will ask for.
'docsync.model.paperless': 'Files by tag',
'docsync.model.papra': 'Files by tag, inside an organisation',
'docsync.model.nextcloud': 'Files in a folder',
'docsync.model.opencloud': 'Files in a space',
'docsync.model.synologydrive': 'Files in a folder on the NAS',
// ── The flow bar ───────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Out to the store',
'docsync.flow.toTrek': 'In from the store',
'docsync.flow.documents': 'documents',
'docsync.flow.summary.both': 'Documents move both ways.',
'docsync.flow.summary.pull': 'Documents only come in.',
'docsync.flow.summary.push': 'Documents only go out.',
'docsync.flow.summaryEditable.both': 'Moving both ways. Tap a lane to stop it.',
'docsync.flow.summaryEditable.pull': 'Only coming in. Tap the other lane to send out too.',
'docsync.flow.summaryEditable.push': 'Only going out. Tap the other lane to bring in too.',
// ── One binding ────────────────────────────────────────────────────────────
'docsync.binding.settings': 'Settings',
'docsync.binding.folder': 'Folder',
'docsync.binding.lastRun': 'Last run',
'docsync.binding.autoOff': 'Paused',
'docsync.binding.neverRun': 'not run yet',
'docsync.binding.deleteHint': 'What happens to the copy on the other side.',
'docsync.binding.autoHint': 'Check for changes in the background.',
'docsync.binding.webhookTitle': 'Instant updates',
'docsync.binding.copy': 'Copy',
'docsync.binding.copied': 'Copied',
// ── Connecting ─────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Connect',
'docsync.connect.testing': 'Trying to reach it',
'docsync.connect.okAs': 'Reached it, signed in as {account}',
'docsync.connect.insecureHint': 'For an instance on your own network with a self-signed certificate.',
'docsync.connect.about.paperless':
'TREK files this trip under its own tag and never touches the rest of your archive.',
'docsync.connect.about.papra':
'Pick the organisation this trip belongs to. TREK files it under its own tag inside it.',
'docsync.connect.about.nextcloud':
'Use an app password, not your account password: it survives two-factor and you can revoke it on its own.',
'docsync.connect.about.opencloud': 'TREK gets its own space for this trip, separate from everything else.',
'docsync.connect.about.synologydrive': 'Best a DSM account that only reaches the shared folder this trip should use.',
// ── Picking the container ──────────────────────────────────────────────────
'docsync.scope.title': 'Where should this trip live in {provider}?',
'docsync.scope.intro': 'Only what is in here is synced. Everything else in your store stays out of TREK.',
'docsync.scope.createTitle': 'Make a new one',
'docsync.scope.createAction': 'Create',
'docsync.scope.pickTitle': 'Or use one you already have',
'docsync.scope.search': 'Search',
'docsync.scope.noMatch': 'Nothing matches that.',
// ── Things a person has to decide ──────────────────────────────────────────
'docsync.issues.title': 'Needs a look',
'docsync.issues.conflict': 'Changed in both places. Pick which one to keep.',
'docsync.issues.remote_missing': 'Gone from the store. The TREK copy is still here.',
'docsync.issues.rejected_type': 'This file type is not allowed here.',
'docsync.issues.too_large': 'Bigger than the limit.',
'docsync.issues.error': 'The transfer did not go through.',
'docsync.error.unknown_provider': 'This provider is not available on this instance.',
};
+81 -10
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Sincronización de documentos',
'docsync.subtitle': 'Mantén los documentos de este viaje al día con tu propio gestor de documentos.',
'docsync.noProviders': 'No hay proveedores de documentos disponibles',
'docsync.noProvidersHint': 'Un administrador de la instancia los activa en Admin, Complementos, Documentos.',
'docsync.addProvider': 'Conectar un proveedor',
'docsync.test': 'Probar conexión',
'docsync.connect.optional': 'Opcional',
'docsync.connected': 'Conectado',
'docsync.chooseFolder': 'Elegir carpeta',
'docsync.chooseFolderHint':
'Elige la carpeta, la etiqueta o el espacio que corresponde a este viaje. Solo se sincronizan los documentos que contiene.',
'docsync.noFolders': 'Todavía no se ha encontrado nada en esta instancia.',
'docsync.newFolderPlaceholder': 'Nombre de la nueva carpeta',
'docsync.createFolder': 'Crear',
'docsync.syncNow': 'Sincronizar ahora',
'docsync.unlink': 'Desconectar',
'docsync.confirmUnlink': 'Los documentos siguen en TREK y en el gestor. Solo desaparece el vínculo entre ambos.',
'docsync.syncEnabled': 'Sincronizar automáticamente',
'docsync.direction': 'Dirección',
'docsync.directionBoth': 'En ambos sentidos',
'docsync.directionPull': 'Solo hacia TREK',
'docsync.directionPush': 'Solo hacia el proveedor',
'docsync.deletePolicy': 'Cuando se elimina un documento',
'docsync.deleteUnlink': 'Conservar ambas copias y deshacer el emparejamiento',
'docsync.deleteTrash': 'Mover la otra copia a su papelera',
'docsync.deleteUnlink': 'Conservar ambas copias',
'docsync.deleteTrash': 'Mover a la papelera',
'docsync.webhookHint':
'Pega esta URL en tu proveedor para que los cambios lleguen de inmediato. Sin ella, TREK comprueba cada cierto tiempo.',
@@ -99,6 +93,83 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Desaparecieron casi todos los documentos a la vez, así que no se cambió nada. Comprueba que la carpeta siga montada.',
'docsync.error.unknown': 'Algo salió mal.',
// ── El diálogo ─────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Este viaje',
'docsync.addAnother': 'Añadir otro',
'docsync.syncing': 'Sincronizando',
'docsync.card.pickFolder': 'Conectado, elige una carpeta',
'docsync.empty.title': 'Aún no hay nada conectado',
'docsync.empty.hintOwner':
'Elige un gestor a la izquierda. TREK guarda su propia copia de todo, así que no se pierde nada si desaparece.',
'docsync.empty.hintMember':
'Esto lo configura el propietario del viaje. En ambos casos, los documentos siguen en TREK.',
// Cómo archiva las cosas cada producto. Se muestra antes de que nadie conecte,
// porque es lo que pedirá la siguiente pantalla.
'docsync.model.paperless': 'Archiva por etiqueta',
'docsync.model.papra': 'Archiva por etiqueta, dentro de una organización',
'docsync.model.nextcloud': 'Archiva en una carpeta',
'docsync.model.opencloud': 'Archiva en un espacio',
'docsync.model.synologydrive': 'Archiva en una carpeta del NAS',
// ── La barra de flujo ──────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Hacia el gestor',
'docsync.flow.toTrek': 'Desde el gestor',
'docsync.flow.documents': 'documentos',
'docsync.flow.summary.both': 'Los documentos van en ambos sentidos.',
'docsync.flow.summary.pull': 'Los documentos solo entran.',
'docsync.flow.summary.push': 'Los documentos solo salen.',
'docsync.flow.summaryEditable.both': 'En ambos sentidos. Toca un carril para detenerlo.',
'docsync.flow.summaryEditable.pull': 'Solo entran. Toca el otro carril para enviar también.',
'docsync.flow.summaryEditable.push': 'Solo salen. Toca el otro carril para recibir también.',
// ── Un vínculo ─────────────────────────────────────────────────────────────
'docsync.binding.settings': 'Ajustes',
'docsync.binding.folder': 'Carpeta',
'docsync.binding.lastRun': 'Última ejecución',
'docsync.binding.autoOff': 'En pausa',
'docsync.binding.neverRun': 'aún sin ejecutar',
'docsync.binding.deleteHint': 'Qué pasa con la copia del otro lado.',
'docsync.binding.autoHint': 'Busca cambios en segundo plano.',
'docsync.binding.webhookTitle': 'Actualizaciones instantáneas',
'docsync.binding.copy': 'Copiar',
'docsync.binding.copied': 'Copiado',
// ── Conexión ───────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Conectar',
'docsync.connect.testing': 'Intentando conectar',
'docsync.connect.okAs': 'Conectado, sesión iniciada como {account}',
'docsync.connect.insecureHint': 'Para una instancia de tu propia red con un certificado autofirmado.',
'docsync.connect.about.paperless': 'TREK archiva este viaje con su propia etiqueta y no toca el resto de tu archivo.',
'docsync.connect.about.papra':
'Elige la organización a la que pertenece este viaje. TREK lo archiva dentro con su propia etiqueta.',
'docsync.connect.about.nextcloud':
'Usa una contraseña de aplicación, no la de tu cuenta: funciona con el doble factor y puedes revocarla por separado.',
'docsync.connect.about.opencloud': 'TREK recibe su propio espacio para este viaje, separado de todo lo demás.',
'docsync.connect.about.synologydrive':
'Mejor una cuenta DSM que solo llegue a la carpeta compartida que debe usar este viaje.',
// ── Elegir el contenedor ───────────────────────────────────────────────────
'docsync.scope.title': '¿Dónde debe guardarse este viaje en {provider}?',
'docsync.scope.intro':
'Solo se sincroniza lo que haya aquí dentro. Todo lo demás de tu gestor se queda fuera de TREK.',
'docsync.scope.createTitle': 'Crear uno nuevo',
'docsync.scope.createAction': 'Crear',
'docsync.scope.pickTitle': 'O usa uno que ya tengas',
'docsync.scope.search': 'Buscar',
'docsync.scope.noMatch': 'No hay coincidencias.',
// ── Cosas que alguien tiene que decidir ────────────────────────────────────
'docsync.issues.title': 'Requiere atención',
'docsync.issues.conflict': 'Cambió en los dos sitios. Elige cuál conservar.',
'docsync.issues.remote_missing': 'Ya no está en el gestor. La copia de TREK sigue aquí.',
'docsync.issues.rejected_type': 'Este tipo de archivo no se permite aquí.',
'docsync.issues.too_large': 'Supera el límite.',
'docsync.issues.error': 'La transferencia no se completó.',
'docsync.error.unknown_provider': 'Este proveedor no está disponible en esta instancia.',
};
+83 -10
View File
@@ -2,28 +2,23 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Synchronisation des documents',
'docsync.subtitle': 'Gardez les documents de ce voyage alignés sur votre propre gestionnaire de documents.',
'docsync.noProviders': 'Aucun fournisseur de documents n’est disponible',
'docsync.noProvidersHint': 'Un administrateur de l’instance les active dans Admin, Extensions, Documents.',
'docsync.addProvider': 'Connecter un fournisseur',
'docsync.test': 'Tester la connexion',
'docsync.connect.optional': 'Facultatif',
'docsync.connected': 'Connecté',
'docsync.chooseFolder': 'Choisir le dossier',
'docsync.chooseFolderHint':
'Choisissez le dossier, l’étiquette ou l’espace qui correspond à ce voyage. Seuls les documents qui s’y trouvent sont synchronisés.',
'docsync.noFolders': 'Rien trouvé sur cette instance pour le moment.',
'docsync.newFolderPlaceholder': 'Nom du nouveau dossier',
'docsync.createFolder': 'Créer',
'docsync.syncNow': 'Synchroniser maintenant',
'docsync.unlink': 'Déconnecter',
'docsync.confirmUnlink':
'Les documents restent dans TREK et chez le fournisseur. Seule l’association entre les deux disparaît.',
'docsync.syncEnabled': 'Synchroniser automatiquement',
'docsync.direction': 'Sens',
'docsync.directionBoth': 'Dans les deux sens',
'docsync.directionPull': 'Uniquement vers TREK',
'docsync.directionPush': 'Uniquement vers le fournisseur',
'docsync.deletePolicy': 'À la suppression d’un document',
'docsync.deleteUnlink': 'Garder les deux copies, rompre l’association',
'docsync.deleteTrash': 'Mettre l’autre copie à la corbeille',
'docsync.deleteUnlink': 'Garder les deux copies',
'docsync.deleteTrash': 'Mettre à la corbeille',
'docsync.webhookHint':
'Collez cette URL chez votre fournisseur pour que les changements arrivent immédiatement. Sans cela, TREK vérifie à intervalles réguliers.',
@@ -99,6 +94,84 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'La plupart des documents ont disparu d’un coup, rien n’a donc été modifié. Vérifiez que le dossier est toujours monté.',
'docsync.error.unknown': 'Une erreur est survenue.',
// ── La fenêtre ─────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Ce voyage',
'docsync.addAnother': 'Ajouter un autre',
'docsync.syncing': 'Synchronisation',
'docsync.card.pickFolder': 'Connecté, choisissez un dossier',
'docsync.empty.title': 'Rien de connecté pour le moment',
'docsync.empty.hintOwner':
'Choisissez un fournisseur à gauche. TREK garde sa propre copie de tout, rien n’est donc perdu s’il disparaît.',
'docsync.empty.hintMember':
'C’est le propriétaire du voyage qui met cela en place. Dans tous les cas, les documents restent dans TREK.',
// Comment chaque produit classe les documents. Affiché avant toute connexion,
// car c’est ce que l’écran suivant va demander.
'docsync.model.paperless': 'Classe par étiquette',
'docsync.model.papra': 'Classe par étiquette, dans une organisation',
'docsync.model.nextcloud': 'Classe dans un dossier',
'docsync.model.opencloud': 'Classe dans un espace',
'docsync.model.synologydrive': 'Classe dans un dossier sur le NAS',
// ── La barre de flux ───────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Vers le fournisseur',
'docsync.flow.toTrek': 'Depuis le fournisseur',
'docsync.flow.documents': 'documents',
'docsync.flow.summary.both': 'Les documents circulent dans les deux sens.',
'docsync.flow.summary.pull': 'Les documents ne font qu’entrer.',
'docsync.flow.summary.push': 'Les documents ne font que sortir.',
'docsync.flow.summaryEditable.both': 'Dans les deux sens. Touchez une voie pour l’arrêter.',
'docsync.flow.summaryEditable.pull': 'Entrée seule. Touchez l’autre voie pour envoyer aussi.',
'docsync.flow.summaryEditable.push': 'Sortie seule. Touchez l’autre voie pour recevoir aussi.',
// ── Une association ────────────────────────────────────────────────────────
'docsync.binding.settings': 'Réglages',
'docsync.binding.folder': 'Dossier',
'docsync.binding.lastRun': 'Dernière exécution',
'docsync.binding.autoOff': 'En pause',
'docsync.binding.neverRun': 'jamais exécutée',
'docsync.binding.deleteHint': 'Ce qu’il advient de la copie de l’autre côté.',
'docsync.binding.autoHint': 'Chercher les changements en arrière-plan.',
'docsync.binding.webhookTitle': 'Mises à jour instantanées',
'docsync.binding.copy': 'Copier',
'docsync.binding.copied': 'Copié',
// ── La connexion ───────────────────────────────────────────────────────────
'docsync.connect.submit': 'Connecter',
'docsync.connect.testing': 'Tentative de connexion',
'docsync.connect.okAs': 'Contact établi, connecté en tant que {account}',
'docsync.connect.insecureHint': 'Pour une instance sur votre propre réseau, avec un certificat auto-signé.',
'docsync.connect.about.paperless':
'TREK classe ce voyage sous sa propre étiquette et ne touche jamais au reste de vos archives.',
'docsync.connect.about.papra':
'Choisissez l’organisation à laquelle ce voyage appartient. TREK l’y classe sous sa propre étiquette.',
'docsync.connect.about.nextcloud':
'Utilisez un mot de passe d’application, pas celui de votre compte : il résiste à la double authentification et se révoque séparément.',
'docsync.connect.about.opencloud': 'TREK reçoit son propre espace pour ce voyage, séparé de tout le reste.',
'docsync.connect.about.synologydrive':
'De préférence un compte DSM qui n’atteint que le dossier partagé prévu pour ce voyage.',
// ── Choix du conteneur ─────────────────────────────────────────────────────
'docsync.scope.title': 'Où ce voyage doit-il se trouver dans {provider} ?',
'docsync.scope.intro':
'Seul ce qui se trouve ici est synchronisé. Tout le reste de votre fournisseur reste hors de TREK.',
'docsync.scope.createTitle': 'En créer un nouveau',
'docsync.scope.createAction': 'Créer',
'docsync.scope.pickTitle': 'Ou en utiliser un existant',
'docsync.scope.search': 'Rechercher',
'docsync.scope.noMatch': 'Aucun résultat.',
// ── Ce qui demande une décision ────────────────────────────────────────────
'docsync.issues.title': 'À vérifier',
'docsync.issues.conflict': 'Modifié des deux côtés. Choisissez la version à garder.',
'docsync.issues.remote_missing': 'Disparu chez le fournisseur. La copie TREK est toujours là.',
'docsync.issues.rejected_type': 'Ce type de fichier n’est pas autorisé ici.',
'docsync.issues.too_large': 'Dépasse la limite.',
'docsync.issues.error': 'Le transfert n’a pas abouti.',
'docsync.error.unknown_provider': 'Ce fournisseur n’est pas disponible sur cette instance.',
};
+82 -10
View File
@@ -2,28 +2,23 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Συγχρονισμός εγγράφων',
'docsync.subtitle': 'Κρατήστε τα έγγραφα αυτού του ταξιδιού στην ίδια κατάσταση με τον δικό σας χώρο εγγράφων.',
'docsync.noProviders': 'Δεν υπάρχουν διαθέσιμοι πάροχοι εγγράφων',
'docsync.noProvidersHint': 'Τους ενεργοποιεί ένας διαχειριστής της εγκατάστασης στο Διαχείριση → Πρόσθετα → Έγγραφα.',
'docsync.addProvider': 'Σύνδεση παρόχου',
'docsync.test': 'Δοκιμή σύνδεσης',
'docsync.connect.optional': 'Προαιρετικό',
'docsync.connected': 'Συνδεδεμένο',
'docsync.chooseFolder': 'Επιλογή φακέλου',
'docsync.chooseFolderHint':
'Επιλέξτε τον φάκελο, την ετικέτα ή τον χώρο που ανήκει σε αυτό το ταξίδι. Συγχρονίζονται μόνο τα έγγραφα που βρίσκονται εκεί.',
'docsync.noFolders': 'Δεν βρέθηκε ακόμη τίποτα σε αυτή την εγκατάσταση.',
'docsync.newFolderPlaceholder': 'Όνομα νέου φακέλου',
'docsync.createFolder': 'Δημιουργία',
'docsync.syncNow': 'Συγχρονισμός τώρα',
'docsync.unlink': 'Αποσύνδεση',
'docsync.confirmUnlink':
'Τα έγγραφα παραμένουν στο TREK και στον χώρο αποθήκευσης. Καταργείται μόνο η αντιστοίχισή τους.',
'docsync.syncEnabled': 'Αυτόματος συγχρονισμός',
'docsync.direction': 'Κατεύθυνση',
'docsync.directionBoth': 'Και προς τις δύο κατευθύνσεις',
'docsync.directionPull': 'Μόνο προς το TREK',
'docsync.directionPush': 'Μόνο προς τον πάροχο',
'docsync.deletePolicy': 'Όταν διαγράφεται ένα έγγραφο',
'docsync.deleteUnlink': 'Διατήρηση και των δύο αντιγράφων, κατάργηση της αντιστοίχισης',
'docsync.deleteTrash': 'Μετακίνηση του άλλου αντιγράφου στον κάδο του',
'docsync.deleteUnlink': 'Διατήρηση και των δύο αντιγράφων',
'docsync.deleteTrash': 'Μετακίνηση στον κάδο ανακύκλωσης',
'docsync.webhookHint':
'Επικολλήστε αυτό το URL στον πάροχό σας ώστε οι αλλαγές να φτάνουν αμέσως. Χωρίς αυτό, το TREK ελέγχει ανά τακτά διαστήματα.',
@@ -101,6 +96,83 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Τα περισσότερα έγγραφα εξαφανίστηκαν μονομιάς, οπότε δεν άλλαξε τίποτα. Ελέγξτε ότι ο φάκελος είναι ακόμη προσαρτημένος.',
'docsync.error.unknown': 'Κάτι πήγε στραβά.',
// ── Ο διάλογος ─────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Αυτό το ταξίδι',
'docsync.addAnother': 'Προσθήκη ακόμη ενός',
'docsync.syncing': 'Συγχρονίζεται',
'docsync.card.pickFolder': 'Συνδέθηκε, επιλέξτε φάκελο',
'docsync.empty.title': 'Δεν έχει συνδεθεί ακόμη τίποτα',
'docsync.empty.hintOwner':
'Επιλέξτε έναν χώρο αποθήκευσης στα αριστερά. Το TREK κρατά δικό του αντίγραφο για όλα, οπότε δεν χάνεται τίποτα αν εκείνος πάψει να υπάρχει.',
'docsync.empty.hintMember': 'Αυτό το ρυθμίζει ο κάτοχος του ταξιδιού. Τα έγγραφα παραμένουν έτσι κι αλλιώς στο TREK.',
// Πώς αρχειοθετεί το καθένα. Εμφανίζεται πριν συνδεθεί κανείς, γιατί είναι
// αυτό που θα ζητήσει η επόμενη οθόνη.
'docsync.model.paperless': 'Αρχειοθέτηση με ετικέτα',
'docsync.model.papra': 'Αρχειοθέτηση με ετικέτα, μέσα σε έναν οργανισμό',
'docsync.model.nextcloud': 'Αρχειοθέτηση σε φάκελο',
'docsync.model.opencloud': 'Αρχειοθέτηση σε χώρο',
'docsync.model.synologydrive': 'Αρχειοθέτηση σε φάκελο στο NAS',
// ── Η μπάρα ροής ───────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Προς τον χώρο',
'docsync.flow.toTrek': 'Από τον χώρο',
'docsync.flow.documents': 'έγγραφα',
'docsync.flow.summary.both': 'Τα έγγραφα κινούνται και προς τις δύο πλευρές.',
'docsync.flow.summary.pull': 'Τα έγγραφα μόνο έρχονται.',
'docsync.flow.summary.push': 'Τα έγγραφα μόνο φεύγουν.',
'docsync.flow.summaryEditable.both': 'Κινούνται και προς τις δύο πλευρές. Πατήστε μια λωρίδα για να τη σταματήσετε.',
'docsync.flow.summaryEditable.pull': 'Μόνο έρχονται. Πατήστε την άλλη λωρίδα για να φεύγουν κιόλας.',
'docsync.flow.summaryEditable.push': 'Μόνο φεύγουν. Πατήστε την άλλη λωρίδα για να έρχονται κιόλας.',
// ── Μία αντιστοίχιση ───────────────────────────────────────────────────────
'docsync.binding.settings': 'Ρυθμίσεις',
'docsync.binding.folder': 'Φάκελος',
'docsync.binding.lastRun': 'Τελευταία εκτέλεση',
'docsync.binding.autoOff': 'Σε παύση',
'docsync.binding.neverRun': 'δεν έχει εκτελεστεί ακόμη',
'docsync.binding.deleteHint': 'Τι γίνεται με το αντίγραφο στην άλλη πλευρά.',
'docsync.binding.autoHint': 'Έλεγχος για αλλαγές στο παρασκήνιο.',
'docsync.binding.webhookTitle': 'Άμεσες ενημερώσεις',
'docsync.binding.copy': 'Αντιγραφή',
'docsync.binding.copied': 'Αντιγράφηκε',
// ── Η σύνδεση ──────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Σύνδεση',
'docsync.connect.testing': 'Γίνεται προσπάθεια επικοινωνίας',
'docsync.connect.okAs': 'Επιτεύχθηκε επικοινωνία, σύνδεση ως {account}',
'docsync.connect.insecureHint': 'Για εγκατάσταση στο δικό σας δίκτυο με αυτο-υπογεγραμμένο πιστοποιητικό.',
'docsync.connect.about.paperless':
'Το TREK αρχειοθετεί αυτό το ταξίδι με δική του ετικέτα και δεν αγγίζει το υπόλοιπο αρχείο σας.',
'docsync.connect.about.papra':
'Επιλέξτε τον οργανισμό στον οποίο ανήκει αυτό το ταξίδι. Το TREK το αρχειοθετεί εκεί με δική του ετικέτα.',
'docsync.connect.about.nextcloud':
'Χρησιμοποιήστε κωδικό εφαρμογής, όχι τον κωδικό του λογαριασμού σας: αντέχει την ταυτοποίηση δύο παραγόντων και μπορείτε να τον ανακαλέσετε ξεχωριστά.',
'docsync.connect.about.opencloud': 'Το TREK αποκτά δικό του χώρο για αυτό το ταξίδι, ξεχωριστό από όλα τα άλλα.',
'docsync.connect.about.synologydrive':
'Καλύτερα ένας λογαριασμός DSM που φτάνει μόνο στον κοινόχρηστο φάκελο που θα χρησιμοποιεί αυτό το ταξίδι.',
// ── Η επιλογή του φακέλου ──────────────────────────────────────────────────
'docsync.scope.title': 'Πού θα βρίσκεται αυτό το ταξίδι στο {provider};',
'docsync.scope.intro':
'Συγχρονίζεται μόνο ό,τι βρίσκεται εδώ. Όλα τα υπόλοιπα στον χώρο αποθήκευσής σας μένουν έξω από το TREK.',
'docsync.scope.createTitle': 'Δημιουργία νέου',
'docsync.scope.createAction': 'Δημιουργία',
'docsync.scope.pickTitle': 'Ή χρησιμοποιήστε κάποιο που ήδη έχετε',
'docsync.scope.search': 'Αναζήτηση',
'docsync.scope.noMatch': 'Δεν ταιριάζει τίποτα με αυτό.',
// ── Όσα πρέπει να αποφασίσει κάποιος ───────────────────────────────────────
'docsync.issues.title': 'Χρειάζεται έλεγχο',
'docsync.issues.conflict': 'Άλλαξε και στις δύο πλευρές. Επιλέξτε ποιο θα κρατήσετε.',
'docsync.issues.remote_missing': 'Χάθηκε από τον χώρο αποθήκευσης. Το αντίγραφο του TREK είναι ακόμη εδώ.',
'docsync.issues.rejected_type': 'Αυτός ο τύπος αρχείου δεν επιτρέπεται εδώ.',
'docsync.issues.too_large': 'Μεγαλύτερο από το όριο.',
'docsync.issues.error': 'Η μεταφορά δεν ολοκληρώθηκε.',
'docsync.error.unknown_provider': 'Αυτός ο πάροχος δεν είναι διαθέσιμος σε αυτή την εγκατάσταση.',
};
+81 -10
View File
@@ -2,28 +2,23 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Dokumentumok szinkronizálása',
'docsync.subtitle': 'Tartsd szinkronban ennek az útnak a dokumentumait a saját dokumentumtárolóddal.',
'docsync.noProviders': 'Nincs elérhető dokumentumszolgáltató',
'docsync.noProvidersHint': 'A példány rendszergazdája kapcsolja be ezeket az Admin, Bővítmények, Dokumentumok alatt.',
'docsync.addProvider': 'Szolgáltató csatlakoztatása',
'docsync.test': 'Kapcsolat tesztelése',
'docsync.connect.optional': 'Nem kötelező',
'docsync.connected': 'Kapcsolódva',
'docsync.chooseFolder': 'Mappa kiválasztása',
'docsync.chooseFolderHint':
'Válaszd ki az ehhez az úthoz tartozó mappát, címkét vagy teret. Csak az abban lévő dokumentumok szinkronizálódnak.',
'docsync.noFolders': 'Ezen a példányon még nem található semmi.',
'docsync.newFolderPlaceholder': 'Az új mappa neve',
'docsync.createFolder': 'Létrehozás',
'docsync.syncNow': 'Szinkronizálás most',
'docsync.unlink': 'Leválasztás',
'docsync.confirmUnlink':
'A dokumentumok a TREK-ben és a tárolóban is megmaradnak. Csak a köztük lévő párosítás szűnik meg.',
'docsync.syncEnabled': 'Automatikus szinkronizálás',
'docsync.direction': 'Irány',
'docsync.directionBoth': 'Mindkét irányban',
'docsync.directionPull': 'Csak a TREK-be',
'docsync.directionPush': 'Csak a szolgáltató felé',
'docsync.deletePolicy': 'Ha egy dokumentumot törölnek',
'docsync.deleteUnlink': 'Mindkét példány maradjon meg, csak a párosítás szűnjön meg',
'docsync.deleteTrash': 'A másik példány kerüljön a saját kukájába',
'docsync.deleteUnlink': 'Mindkét példány megtartása',
'docsync.deleteTrash': 'Áthelyezés a kukába',
'docsync.webhookHint':
'Illeszd be ezt az URL-t a szolgáltatódnál, hogy a változások azonnal megérkezzenek. Enélkül a TREK időzítve ellenőriz.',
@@ -100,6 +95,82 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Egyszerre tűnt el a dokumentumok nagy része, ezért semmi nem változott. Ellenőrizd, hogy a mappa még csatolva van-e.',
'docsync.error.unknown': 'Valami hiba történt.',
// ── A párbeszédablak ───────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Ez az út',
'docsync.addAnother': 'Másik hozzáadása',
'docsync.syncing': 'Szinkronizálás',
'docsync.card.pickFolder': 'Kapcsolódva, válassz mappát',
'docsync.empty.title': 'Még nincs semmi csatlakoztatva',
'docsync.empty.hintOwner':
'Válassz egy tárolót a bal oldalon. A TREK mindenről saját másolatot tart, így semmi nem vész el, ha a tároló megszűnik.',
'docsync.empty.hintMember': 'Ezt az út tulajdonosa állítja be. A dokumentumok így is, úgy is megmaradnak a TREK-ben.',
// Hogyan rendszerez az egyes termékek. A csatlakozás előtt látszik, mert ez az,
// amit a következő képernyő kérni fog.
'docsync.model.paperless': 'Címke szerint rendszerez',
'docsync.model.papra': 'Címke szerint rendszerez, egy szervezeten belül',
'docsync.model.nextcloud': 'Mappába rendszerez',
'docsync.model.opencloud': 'Térbe rendszerez',
'docsync.model.synologydrive': 'A NAS egyik mappájába rendszerez',
// ── A folyamatsáv ──────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Ki a tárolóba',
'docsync.flow.toTrek': 'Be a tárolóból',
'docsync.flow.documents': 'dokumentum',
'docsync.flow.summary.both': 'A dokumentumok oda-vissza mozognak.',
'docsync.flow.summary.pull': 'A dokumentumok csak befelé jönnek.',
'docsync.flow.summary.push': 'A dokumentumok csak kifelé mennek.',
'docsync.flow.summaryEditable.both': 'Oda-vissza mozognak. Koppints egy sávra a leállításhoz.',
'docsync.flow.summaryEditable.pull': 'Csak befelé. Koppints a másik sávra a kiküldéshez is.',
'docsync.flow.summaryEditable.push': 'Csak kifelé. Koppints a másik sávra a behozatalhoz is.',
// ── Egy összekapcsolás ─────────────────────────────────────────────────────
'docsync.binding.settings': 'Beállítások',
'docsync.binding.folder': 'Mappa',
'docsync.binding.lastRun': 'Utolsó futás',
'docsync.binding.autoOff': 'Szüneteltetve',
'docsync.binding.neverRun': 'még nem futott',
'docsync.binding.deleteHint': 'Mi történjen a másik oldalon lévő példánnyal.',
'docsync.binding.autoHint': 'Változások keresése a háttérben.',
'docsync.binding.webhookTitle': 'Azonnali frissítések',
'docsync.binding.copy': 'Másolás',
'docsync.binding.copied': 'Másolva',
// ── Csatlakozás ────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Csatlakozás',
'docsync.connect.testing': 'Próbálom elérni',
'docsync.connect.okAs': 'Elértem, bejelentkezve mint {account}',
'docsync.connect.insecureHint': 'Saját hálózaton futó, saját aláírású tanúsítványt használó példányhoz.',
'docsync.connect.about.paperless':
'A TREK saját címke alá rendezi ezt az utat, és az archívumod többi részéhez nem nyúl.',
'docsync.connect.about.papra':
'Válaszd ki a szervezetet, amelyhez ez az út tartozik. A TREK azon belül saját címke alá rendezi.',
'docsync.connect.about.nextcloud':
'Alkalmazásjelszót használj, ne a fiókod jelszavát: átmegy a kétlépcsős azonosításon, és külön is visszavonható.',
'docsync.connect.about.opencloud': 'A TREK saját teret kap ehhez az úthoz, mindentől elkülönítve.',
'docsync.connect.about.synologydrive':
'Legjobb egy olyan DSM-fiók, amely csak az ehhez az úthoz tartozó megosztott mappát éri el.',
// ── A tároló kiválasztása ──────────────────────────────────────────────────
'docsync.scope.title': 'Hová kerüljön ez az út a(z) {provider} tárolóban?',
'docsync.scope.intro': 'Csak az szinkronizálódik, ami itt van. A tárolód minden más tartalma kívül marad a TREK-en.',
'docsync.scope.createTitle': 'Új létrehozása',
'docsync.scope.createAction': 'Létrehozás',
'docsync.scope.pickTitle': 'Vagy használj egy meglévőt',
'docsync.scope.search': 'Keresés',
'docsync.scope.noMatch': 'Erre nincs találat.',
// ── Amikről dönteni kell ───────────────────────────────────────────────────
'docsync.issues.title': 'Átnézést igényel',
'docsync.issues.conflict': 'Mindkét helyen megváltozott. Válaszd ki, melyik maradjon.',
'docsync.issues.remote_missing': 'Eltűnt a tárolóból. A TREK-példány még megvan.',
'docsync.issues.rejected_type': 'Ez a fájltípus itt nem engedélyezett.',
'docsync.issues.too_large': 'Nagyobb a megengedettnél.',
'docsync.issues.error': 'Az átvitel nem sikerült.',
'docsync.error.unknown_provider': 'Ez a szolgáltató nem érhető el ezen a példányon.',
};
+81 -10
View File
@@ -2,28 +2,23 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Sinkronisasi dokumen',
'docsync.subtitle': 'Jaga dokumen perjalanan ini tetap selaras dengan penyimpanan dokumen milikmu sendiri.',
'docsync.noProviders': 'Tidak ada penyedia dokumen yang tersedia',
'docsync.noProvidersHint': 'Administrator instans mengaktifkannya di Admin → Addon → Dokumen.',
'docsync.addProvider': 'Hubungkan penyedia',
'docsync.test': 'Uji koneksi',
'docsync.connect.optional': 'Opsional',
'docsync.connected': 'Terhubung',
'docsync.chooseFolder': 'Pilih folder',
'docsync.chooseFolderHint':
'Pilih folder, tag, atau ruang yang menjadi milik perjalanan ini. Hanya dokumen di dalamnya yang disinkronkan.',
'docsync.noFolders': 'Belum ada yang ditemukan di instans ini.',
'docsync.newFolderPlaceholder': 'Nama folder baru',
'docsync.createFolder': 'Buat',
'docsync.syncNow': 'Sinkronkan sekarang',
'docsync.unlink': 'Putuskan',
'docsync.confirmUnlink':
'Dokumen tetap ada di TREK dan di penyimpananmu. Hanya pasangan di antara keduanya yang dilepas.',
'docsync.syncEnabled': 'Sinkronkan otomatis',
'docsync.direction': 'Arah',
'docsync.directionBoth': 'Dua arah',
'docsync.directionPull': 'Hanya ke dalam TREK',
'docsync.directionPush': 'Hanya ke penyedia',
'docsync.deletePolicy': 'Saat sebuah dokumen dihapus',
'docsync.deleteUnlink': 'Simpan kedua salinan, lepaskan pasangannya',
'docsync.deleteTrash': 'Pindahkan salinan satunya ke tempat sampahnya',
'docsync.deleteUnlink': 'Simpan kedua salinan',
'docsync.deleteTrash': 'Pindahkan ke tempat sampah',
'docsync.webhookHint':
'Tempelkan URL ini di penyediamu agar perubahan langsung tiba. Tanpa itu, TREK memeriksa secara berkala.',
@@ -99,6 +94,82 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Hampir semua dokumen hilang sekaligus, jadi tidak ada yang diubah. Periksa apakah foldernya masih terpasang.',
'docsync.error.unknown': 'Ada yang tidak beres.',
// ── Dialognya ──────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Perjalanan ini',
'docsync.addAnother': 'Tambah lagi',
'docsync.syncing': 'Menyinkronkan',
'docsync.card.pickFolder': 'Terhubung, pilih folder',
'docsync.empty.title': 'Belum ada yang terhubung',
'docsync.empty.hintOwner':
'Pilih penyimpanan di sebelah kiri. TREK menyimpan salinannya sendiri untuk semuanya, jadi tidak ada yang hilang kalau penyimpanan itu lenyap.',
'docsync.empty.hintMember': 'Pemilik perjalanan yang mengaturnya. Dokumen tetap ada di TREK, apa pun pilihannya.',
// Cara tiap produk mengarsipkan. Ditampilkan sebelum ada yang menghubungkan,
// karena inilah yang akan ditanyakan layar berikutnya.
'docsync.model.paperless': 'Mengarsipkan per tag',
'docsync.model.papra': 'Mengarsipkan per tag, di dalam sebuah organisasi',
'docsync.model.nextcloud': 'Mengarsipkan di dalam folder',
'docsync.model.opencloud': 'Mengarsipkan di dalam sebuah ruang',
'docsync.model.synologydrive': 'Mengarsipkan di dalam folder di NAS',
// ── Bilah alur ─────────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Keluar ke penyimpanan',
'docsync.flow.toTrek': 'Masuk dari penyimpanan',
'docsync.flow.documents': 'dokumen',
'docsync.flow.summary.both': 'Dokumen bergerak dua arah.',
'docsync.flow.summary.pull': 'Dokumen hanya masuk.',
'docsync.flow.summary.push': 'Dokumen hanya keluar.',
'docsync.flow.summaryEditable.both': 'Bergerak dua arah. Ketuk satu jalur untuk menghentikannya.',
'docsync.flow.summaryEditable.pull': 'Hanya masuk. Ketuk jalur satunya agar ikut dikirim keluar.',
'docsync.flow.summaryEditable.push': 'Hanya keluar. Ketuk jalur satunya agar ikut dibawa masuk.',
// ── Satu pasangan ──────────────────────────────────────────────────────────
'docsync.binding.settings': 'Pengaturan',
'docsync.binding.folder': 'Folder',
'docsync.binding.lastRun': 'Terakhir dijalankan',
'docsync.binding.autoOff': 'Dijeda',
'docsync.binding.neverRun': 'belum pernah dijalankan',
'docsync.binding.deleteHint': 'Apa yang terjadi pada salinan di sisi satunya.',
'docsync.binding.autoHint': 'Periksa perubahan di latar belakang.',
'docsync.binding.webhookTitle': 'Pembaruan seketika',
'docsync.binding.copy': 'Salin',
'docsync.binding.copied': 'Tersalin',
// ── Menghubungkan ──────────────────────────────────────────────────────────
'docsync.connect.submit': 'Hubungkan',
'docsync.connect.testing': 'Mencoba menjangkaunya',
'docsync.connect.okAs': 'Terjangkau, masuk sebagai {account}',
'docsync.connect.insecureHint': 'Untuk instans di jaringanmu sendiri dengan sertifikat yang ditandatangani sendiri.',
'docsync.connect.about.paperless':
'TREK mengarsipkan perjalanan ini di bawah tag miliknya sendiri dan tidak pernah menyentuh sisa arsipmu.',
'docsync.connect.about.papra':
'Pilih organisasi tempat perjalanan ini berada. TREK mengarsipkannya di bawah tag miliknya sendiri di dalamnya.',
'docsync.connect.about.nextcloud':
'Pakai sandi aplikasi, bukan kata sandi akunmu: sandi aplikasi tahan terhadap dua faktor dan bisa dicabut sendiri.',
'docsync.connect.about.opencloud': 'TREK mendapat ruang sendiri untuk perjalanan ini, terpisah dari yang lain.',
'docsync.connect.about.synologydrive':
'Sebaiknya akun DSM yang hanya menjangkau folder bersama untuk perjalanan ini.',
// ── Memilih wadahnya ───────────────────────────────────────────────────────
'docsync.scope.title': 'Di mana perjalanan ini disimpan di {provider}?',
'docsync.scope.intro': 'Hanya isi di sini yang disinkronkan. Semua hal lain di penyimpananmu tetap di luar TREK.',
'docsync.scope.createTitle': 'Buat yang baru',
'docsync.scope.createAction': 'Buat',
'docsync.scope.pickTitle': 'Atau pakai yang sudah ada',
'docsync.scope.search': 'Cari',
'docsync.scope.noMatch': 'Tidak ada yang cocok.',
// ── Hal yang harus diputuskan orang ────────────────────────────────────────
'docsync.issues.title': 'Perlu dilihat',
'docsync.issues.conflict': 'Berubah di kedua tempat. Pilih mana yang dipertahankan.',
'docsync.issues.remote_missing': 'Hilang dari penyimpanan. Salinan TREK masih ada.',
'docsync.issues.rejected_type': 'Tipe file ini tidak diizinkan di sini.',
'docsync.issues.too_large': 'Lebih besar dari batas.',
'docsync.issues.error': 'Transfer tidak berhasil.',
'docsync.error.unknown_provider': 'Penyedia ini tidak tersedia di instans ini.',
};
+81 -10
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Sincronizzazione documenti',
'docsync.subtitle': 'Tieni i documenti di questo viaggio allineati con il tuo archivio documenti.',
'docsync.noProviders': 'Nessun provider di documenti disponibile',
'docsync.noProvidersHint': 'Un amministratore dell’istanza li attiva in Admin, Moduli, Documenti.',
'docsync.addProvider': 'Collega un provider',
'docsync.test': 'Prova la connessione',
'docsync.connect.optional': 'Facoltativo',
'docsync.connected': 'Connesso',
'docsync.chooseFolder': 'Scegli la cartella',
'docsync.chooseFolderHint':
'Scegli la cartella, il tag o lo spazio che appartiene a questo viaggio. Vengono sincronizzati solo i documenti al suo interno.',
'docsync.noFolders': 'Non è ancora stato trovato nulla su questa istanza.',
'docsync.newFolderPlaceholder': 'Nome della nuova cartella',
'docsync.createFolder': 'Crea',
'docsync.syncNow': 'Sincronizza ora',
'docsync.unlink': 'Disconnetti',
'docsync.confirmUnlink': 'I documenti restano in TREK e nell’archivio. Sparisce solo l’abbinamento fra i due.',
'docsync.syncEnabled': 'Sincronizza automaticamente',
'docsync.direction': 'Direzione',
'docsync.directionBoth': 'In entrambe le direzioni',
'docsync.directionPull': 'Solo verso TREK',
'docsync.directionPush': 'Solo verso il provider',
'docsync.deletePolicy': 'Quando un documento viene eliminato',
'docsync.deleteUnlink': 'Mantieni entrambe le copie, sciogli l’abbinamento',
'docsync.deleteTrash': 'Sposta l’altra copia nel suo cestino',
'docsync.deleteUnlink': 'Mantieni entrambe le copie',
'docsync.deleteTrash': 'Sposta nel cestino',
'docsync.webhookHint':
'Incolla questo URL nel tuo provider per ricevere subito le modifiche. Senza, TREK controlla a intervalli regolari.',
@@ -99,6 +93,83 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Quasi tutti i documenti sono spariti in una volta, quindi non è stato modificato nulla. Controlla che la cartella sia ancora montata.',
'docsync.error.unknown': 'Qualcosa è andato storto.',
// ── La finestra ────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Questo viaggio',
'docsync.addAnother': 'Aggiungine un altro',
'docsync.syncing': 'Sincronizzazione',
'docsync.card.pickFolder': 'Connesso, scegli una cartella',
'docsync.empty.title': 'Ancora nessun collegamento',
'docsync.empty.hintOwner':
'Scegli un archivio a sinistra. TREK tiene una copia di tutto, quindi non si perde nulla se l’archivio sparisce.',
'docsync.empty.hintMember': 'Se ne occupa il proprietario del viaggio. In ogni caso i documenti restano in TREK.',
// Come ogni prodotto archivia le cose. Si vede prima di collegarsi, perché è
// quello che chiederà la schermata successiva.
'docsync.model.paperless': 'Archivia per tag',
'docsync.model.papra': 'Archivia per tag, dentro un’organizzazione',
'docsync.model.nextcloud': 'Archivia in una cartella',
'docsync.model.opencloud': 'Archivia in uno spazio',
'docsync.model.synologydrive': 'Archivia in una cartella sul NAS',
// ── La barra del flusso ────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Verso l’archivio',
'docsync.flow.toTrek': 'Dall’archivio',
'docsync.flow.documents': 'documenti',
'docsync.flow.summary.both': 'Documenti in entrambe le direzioni.',
'docsync.flow.summary.pull': 'Documenti solo in entrata.',
'docsync.flow.summary.push': 'Documenti solo in uscita.',
'docsync.flow.summaryEditable.both': 'In entrambe le direzioni. Tocca una corsia per fermarla.',
'docsync.flow.summaryEditable.pull': 'Solo in entrata. Tocca l’altra corsia per inviare anche in uscita.',
'docsync.flow.summaryEditable.push': 'Solo in uscita. Tocca l’altra corsia per ricevere anche in entrata.',
// ── Un abbinamento ─────────────────────────────────────────────────────────
'docsync.binding.settings': 'Impostazioni',
'docsync.binding.folder': 'Cartella',
'docsync.binding.lastRun': 'Ultima esecuzione',
'docsync.binding.autoOff': 'In pausa',
'docsync.binding.neverRun': 'mai eseguita',
'docsync.binding.deleteHint': 'Che cosa succede alla copia dall’altra parte.',
'docsync.binding.autoHint': 'Controlla le modifiche in background.',
'docsync.binding.webhookTitle': 'Aggiornamenti immediati',
'docsync.binding.copy': 'Copia',
'docsync.binding.copied': 'Copiato',
// ── Connessione ────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Connetti',
'docsync.connect.testing': 'Tentativo di connessione',
'docsync.connect.okAs': 'Raggiunto, accesso come {account}',
'docsync.connect.insecureHint': 'Per un’istanza sulla tua rete con un certificato autofirmato.',
'docsync.connect.about.paperless':
'TREK archivia questo viaggio sotto un tag suo e non tocca il resto del tuo archivio.',
'docsync.connect.about.papra':
'Scegli l’organizzazione a cui appartiene questo viaggio. TREK lo archivia lì sotto un tag suo.',
'docsync.connect.about.nextcloud':
'Usa una password per applicazioni, non quella del tuo account: regge l’autenticazione a due fattori e puoi revocarla da sola.',
'docsync.connect.about.opencloud': 'TREK riceve uno spazio tutto suo per questo viaggio, separato da tutto il resto.',
'docsync.connect.about.synologydrive':
'Meglio un account DSM che raggiunga solo la cartella condivisa destinata a questo viaggio.',
// ── Scelta del contenitore ─────────────────────────────────────────────────
'docsync.scope.title': 'Dove deve stare questo viaggio in {provider}?',
'docsync.scope.intro':
'Viene sincronizzato solo quello che sta qui dentro. Tutto il resto del tuo archivio resta fuori da TREK.',
'docsync.scope.createTitle': 'Creane uno nuovo',
'docsync.scope.createAction': 'Crea',
'docsync.scope.pickTitle': 'Oppure usane uno che hai già',
'docsync.scope.search': 'Cerca',
'docsync.scope.noMatch': 'Nessun risultato.',
// ── Cose da decidere ───────────────────────────────────────────────────────
'docsync.issues.title': 'Da controllare',
'docsync.issues.conflict': 'Cambiato da entrambe le parti. Scegli quale copia tenere.',
'docsync.issues.remote_missing': 'Sparito dall’archivio. La copia in TREK c’è ancora.',
'docsync.issues.rejected_type': 'Questo tipo di file non è consentito qui.',
'docsync.issues.too_large': 'Oltre il limite di dimensione.',
'docsync.issues.error': 'Il trasferimento non è andato a buon fine.',
'docsync.error.unknown_provider': 'Questo provider non è disponibile su questa istanza.',
};
+77 -10
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'ドキュメント同期',
'docsync.subtitle': 'この旅行のドキュメントを、自分のドキュメント保管先と同じ状態に保ちます。',
'docsync.noProviders': '利用できる連携先がありません',
'docsync.noProvidersHint': 'インスタンスの管理者が「管理 → アドオン → ドキュメント」で有効にします。',
'docsync.addProvider': '連携先を接続',
'docsync.test': '接続をテスト',
'docsync.connect.optional': '任意',
'docsync.connected': '接続済み',
'docsync.chooseFolder': 'フォルダーを選択',
'docsync.chooseFolderHint':
'この旅行に対応するフォルダー、タグ、スペースを選びます。その中のドキュメントだけが同期されます。',
'docsync.noFolders': 'このインスタンスにはまだ何もありません。',
'docsync.newFolderPlaceholder': '新しいフォルダー名',
'docsync.createFolder': '作成',
'docsync.syncNow': '今すぐ同期',
'docsync.unlink': '接続解除',
'docsync.confirmUnlink': 'ドキュメントは TREK にも保管先にも残ります。なくなるのは両者の対応付けだけです。',
'docsync.syncEnabled': '自動で同期',
'docsync.direction': '方向',
'docsync.directionBoth': '双方向',
'docsync.directionPull': 'TREK に取り込むだけ',
'docsync.directionPush': '連携先へ送るだけ',
'docsync.deletePolicy': 'ドキュメントが削除されたとき',
'docsync.deleteUnlink': '両方のコピーを残し、対応付けだけを解除する',
'docsync.deleteTrash': 'もう一方のコピーをゴミ箱へ移動する',
'docsync.deleteUnlink': '両方のコピーを残す',
'docsync.deleteTrash': 'ゴミ箱へ移動する',
'docsync.webhookHint':
'この URL を連携先に貼り付けると、変更がすぐに届きます。設定しない場合、TREK は一定間隔で確認します。',
@@ -97,6 +91,79 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'多数のドキュメントが一度に消えたため、何も変更しませんでした。フォルダーがまだマウントされているか確認してください。',
'docsync.error.unknown': '問題が発生しました。',
// ── ダイアログ ─────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'この旅行',
'docsync.addAnother': '別の連携先を追加',
'docsync.syncing': '同期中',
'docsync.card.pickFolder': '接続済み。フォルダーを選んでください',
'docsync.empty.title': 'まだ何も接続されていません',
'docsync.empty.hintOwner':
'左から保管先を選んでください。TREK はすべてのコピーを自分で持つので、保管先がなくなっても失われるものはありません。',
'docsync.empty.hintMember': 'この設定は旅行の所有者が行います。どちらにしてもドキュメントは TREK に残ります。',
// それぞれの製品が何を単位に整理するか。接続する前に表示します。次の画面で
// 聞かれるのがまさにこれだからです。
'docsync.model.paperless': 'タグで整理',
'docsync.model.papra': '組織の中でタグごとに整理',
'docsync.model.nextcloud': 'フォルダーで整理',
'docsync.model.opencloud': 'スペースで整理',
'docsync.model.synologydrive': 'NAS 上のフォルダーで整理',
// ── フローバー ─────────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': '保管先へ',
'docsync.flow.toTrek': '保管先から',
'docsync.flow.documents': 'ドキュメント',
'docsync.flow.summary.both': 'ドキュメントは双方向に流れます。',
'docsync.flow.summary.pull': 'ドキュメントは取り込むだけです。',
'docsync.flow.summary.push': 'ドキュメントは送り出すだけです。',
'docsync.flow.summaryEditable.both': '双方向に流れています。レーンをタップすると止まります。',
'docsync.flow.summaryEditable.pull': '取り込むだけです。もう一方のレーンをタップすると送信も行います。',
'docsync.flow.summaryEditable.push': '送り出すだけです。もう一方のレーンをタップすると取り込みも行います。',
// ── ひとつの対応付け ───────────────────────────────────────────────────────
'docsync.binding.settings': '設定',
'docsync.binding.folder': 'フォルダー',
'docsync.binding.lastRun': '最終実行',
'docsync.binding.autoOff': '一時停止中',
'docsync.binding.neverRun': '未実行',
'docsync.binding.deleteHint': 'もう一方に残るコピーをどう扱うかです。',
'docsync.binding.autoHint': 'バックグラウンドで変更を確認します。',
'docsync.binding.webhookTitle': '即時更新',
'docsync.binding.copy': 'コピー',
'docsync.binding.copied': 'コピーしました',
// ── 接続 ───────────────────────────────────────────────────────────────────
'docsync.connect.submit': '接続',
'docsync.connect.testing': '接続を確認しています',
'docsync.connect.okAs': '接続できました。{account} としてサインインしています',
'docsync.connect.insecureHint': '自己署名証明書を使う、自分のネットワーク上のインスタンス向けです。',
'docsync.connect.about.paperless': 'TREK はこの旅行を専用のタグで管理し、アーカイブのほかの部分には触れません。',
'docsync.connect.about.papra': 'この旅行が属する組織を選びます。TREK はその中に専用のタグを作って管理します。',
'docsync.connect.about.nextcloud':
'アカウントのパスワードではなくアプリパスワードを使ってください。二段階認証があっても使えて、単独で失効できます。',
'docsync.connect.about.opencloud': 'TREK はこの旅行のために専用のスペースを持ち、ほかとは分けて扱います。',
'docsync.connect.about.synologydrive': 'この旅行で使う共有フォルダーだけにアクセスできる DSM アカウントが最適です。',
// ── 置き場所を選ぶ ─────────────────────────────────────────────────────────
'docsync.scope.title': 'この旅行を {provider} のどこに置きますか?',
'docsync.scope.intro': 'ここに入っているものだけが同期されます。保管先のほかのものは TREK に入りません。',
'docsync.scope.createTitle': '新しく作る',
'docsync.scope.createAction': '作成',
'docsync.scope.pickTitle': 'または既にあるものを使う',
'docsync.scope.search': '検索',
'docsync.scope.noMatch': '一致するものがありません。',
// ── 人が判断する必要があるもの ─────────────────────────────────────────────
'docsync.issues.title': '確認が必要',
'docsync.issues.conflict': '両方で変更されました。どちらを残すか選んでください。',
'docsync.issues.remote_missing': '保管先から消えました。TREK のコピーは残っています。',
'docsync.issues.rejected_type': 'このファイル形式はここでは許可されていません。',
'docsync.issues.too_large': '上限を超えています。',
'docsync.issues.error': '転送できませんでした。',
'docsync.error.unknown_provider': 'この連携先はこのインスタンスでは利用できません。',
};
+77 -9
View File
@@ -2,27 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': '문서 동기화',
'docsync.subtitle': '이 여행의 문서를 직접 운영하는 문서 저장소와 같은 상태로 유지합니다.',
'docsync.noProviders': '사용할 수 있는 문서 저장소가 없습니다',
'docsync.noProvidersHint': '인스턴스 관리자가 관리 → 애드온 → 문서에서 켤 수 있습니다.',
'docsync.addProvider': '저장소 연결',
'docsync.test': '연결 테스트',
'docsync.connect.optional': '선택 사항',
'docsync.connected': '연결됨',
'docsync.chooseFolder': '폴더 선택',
'docsync.chooseFolderHint': '이 여행에 해당하는 폴더, 태그 또는 공간을 고르세요. 그 안의 문서만 동기화됩니다.',
'docsync.noFolders': '이 인스턴스에서 아직 아무것도 찾지 못했습니다.',
'docsync.newFolderPlaceholder': '새 폴더 이름',
'docsync.createFolder': '만들기',
'docsync.syncNow': '지금 동기화',
'docsync.unlink': '연결 해제',
'docsync.confirmUnlink': '문서는 TREK과 저장소에 그대로 남습니다. 둘 사이의 연결만 사라집니다.',
'docsync.syncEnabled': '자동으로 동기화',
'docsync.direction': '방향',
'docsync.directionBoth': '양방향',
'docsync.directionPull': 'TREK으로 가져오기만',
'docsync.directionPush': '저장소로 보내기만',
'docsync.deletePolicy': '문서가 삭제될 때',
'docsync.deleteUnlink': '양쪽 사본을 그대로 두고 연결만 해제',
'docsync.deleteTrash': '다른 쪽 사본을 휴지통으로 이동',
'docsync.deleteUnlink': '양쪽 사본 유지',
'docsync.deleteTrash': '휴지통으로 이동',
'docsync.webhookHint':
'이 URL을 저장소에 붙여넣으면 변경 사항이 바로 전달됩니다. 설정하지 않으면 TREK이 주기적으로 확인합니다.',
@@ -95,6 +90,79 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'많은 문서가 한꺼번에 사라져 아무것도 변경하지 않았습니다. 폴더가 아직 마운트되어 있는지 확인하세요.',
'docsync.error.unknown': '문제가 발생했습니다.',
// ── 대화 상자 ──────────────────────────────────────────────────────────────
'docsync.sidebar.connected': '이 여행',
'docsync.addAnother': '더 추가',
'docsync.syncing': '동기화 중',
'docsync.card.pickFolder': '연결됨, 폴더를 고르세요',
'docsync.empty.title': '아직 연결된 저장소가 없습니다',
'docsync.empty.hintOwner':
'왼쪽에서 저장소를 고르세요. TREK은 모든 문서의 사본을 따로 보관하므로 저장소가 사라져도 잃는 것은 없습니다.',
'docsync.empty.hintMember': '이 설정은 여행 소유자가 합니다. 어느 쪽이든 문서는 TREK에 그대로 남습니다.',
// 제품마다 문서를 정리하는 방식. 다음 화면에서 물어볼 내용이라 연결하기 전에
// 미리 보여 줍니다.
'docsync.model.paperless': '태그로 정리',
'docsync.model.papra': '조직 안에서 태그로 정리',
'docsync.model.nextcloud': '폴더에 정리',
'docsync.model.opencloud': '스페이스에 정리',
'docsync.model.synologydrive': 'NAS의 폴더에 정리',
// ── 흐름 표시줄 ────────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': '저장소로 내보내기',
'docsync.flow.toTrek': '저장소에서 가져오기',
'docsync.flow.documents': '문서',
'docsync.flow.summary.both': '문서가 양방향으로 오갑니다.',
'docsync.flow.summary.pull': '문서가 들어오기만 합니다.',
'docsync.flow.summary.push': '문서가 나가기만 합니다.',
'docsync.flow.summaryEditable.both': '양방향으로 오갑니다. 한쪽을 누르면 멈춥니다.',
'docsync.flow.summaryEditable.pull': '들어오기만 합니다. 반대쪽을 누르면 내보내기도 합니다.',
'docsync.flow.summaryEditable.push': '나가기만 합니다. 반대쪽을 누르면 가져오기도 합니다.',
// ── 연결 하나 ──────────────────────────────────────────────────────────────
'docsync.binding.settings': '설정',
'docsync.binding.folder': '폴더',
'docsync.binding.lastRun': '마지막 실행',
'docsync.binding.autoOff': '일시 중지됨',
'docsync.binding.neverRun': '아직 실행 안 함',
'docsync.binding.deleteHint': '반대쪽 사본을 어떻게 할지 정합니다.',
'docsync.binding.autoHint': '백그라운드에서 변경 사항을 확인합니다.',
'docsync.binding.webhookTitle': '즉시 업데이트',
'docsync.binding.copy': '복사',
'docsync.binding.copied': '복사됨',
// ── 연결하기 ───────────────────────────────────────────────────────────────
'docsync.connect.submit': '연결',
'docsync.connect.testing': '연결을 시도하는 중',
'docsync.connect.okAs': '연결됐습니다. {account} 계정으로 로그인했습니다',
'docsync.connect.insecureHint': '자체 서명 인증서를 쓰는 내부 네트워크 인스턴스용입니다.',
'docsync.connect.about.paperless': 'TREK은 이 여행을 전용 태그로 정리하며 보관함의 나머지는 건드리지 않습니다.',
'docsync.connect.about.papra': '이 여행이 속할 조직을 고르세요. TREK은 그 안에서 전용 태그로 정리합니다.',
'docsync.connect.about.nextcloud':
'계정 비밀번호 대신 앱 비밀번호를 쓰세요. 2단계 인증에도 영향을 받지 않고 따로 취소할 수 있습니다.',
'docsync.connect.about.opencloud': 'TREK은 이 여행 전용 스페이스를 받아 다른 자료와 분리합니다.',
'docsync.connect.about.synologydrive': '이 여행이 쓸 공유 폴더에만 접근하는 DSM 계정을 권장합니다.',
// ── 저장 위치 고르기 ───────────────────────────────────────────────────────
'docsync.scope.title': '{provider}의 어디에 이 여행을 둘까요?',
'docsync.scope.intro': '여기에 있는 것만 동기화됩니다. 저장소의 나머지는 TREK에 들어오지 않습니다.',
'docsync.scope.createTitle': '새로 만들기',
'docsync.scope.createAction': '만들기',
'docsync.scope.pickTitle': '또는 이미 있는 것 사용하기',
'docsync.scope.search': '검색',
'docsync.scope.noMatch': '일치하는 항목이 없습니다.',
// ── 사람이 정해야 하는 것 ──────────────────────────────────────────────────
'docsync.issues.title': '확인이 필요합니다',
'docsync.issues.conflict': '양쪽에서 바뀌었습니다. 어느 쪽을 남길지 고르세요.',
'docsync.issues.remote_missing': '저장소에서 사라졌습니다. TREK 사본은 그대로 있습니다.',
'docsync.issues.rejected_type': '여기서는 허용되지 않는 파일 형식입니다.',
'docsync.issues.too_large': '허용 크기를 넘습니다.',
'docsync.issues.error': '전송이 완료되지 않았습니다.',
'docsync.error.unknown_provider': '이 저장소는 이 인스턴스에서 사용할 수 없습니다.',
};
+79 -11
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Documentsynchronisatie',
'docsync.subtitle': 'Houd de documenten van deze reis gelijk met je eigen documentopslag.',
'docsync.noProviders': 'Er zijn geen documentaanbieders beschikbaar',
'docsync.noProvidersHint': 'Een beheerder van deze instantie zet ze aan onder Beheer, Add-ons, Documenten.',
'docsync.addProvider': 'Aanbieder koppelen',
'docsync.test': 'Verbinding testen',
'docsync.connect.optional': 'Optioneel',
'docsync.connected': 'Verbonden',
'docsync.chooseFolder': 'Map kiezen',
'docsync.chooseFolderHint':
'Kies de map, tag of ruimte die bij deze reis hoort. Alleen documenten daarin worden gesynchroniseerd.',
'docsync.noFolders': 'Nog niets gevonden op deze instantie.',
'docsync.newFolderPlaceholder': 'Naam van de nieuwe map',
'docsync.createFolder': 'Aanmaken',
'docsync.syncNow': 'Nu synchroniseren',
'docsync.unlink': 'Verbinding verbreken',
'docsync.confirmUnlink': 'Documenten blijven in TREK en in de opslag. Alleen de koppeling ertussen verdwijnt.',
'docsync.syncEnabled': 'Automatisch synchroniseren',
'docsync.direction': 'Richting',
'docsync.directionBoth': 'Beide richtingen',
'docsync.directionPull': 'Alleen naar TREK',
'docsync.directionPush': 'Alleen naar de aanbieder',
'docsync.deletePolicy': 'Als een document wordt verwijderd',
'docsync.deleteUnlink': 'Beide kopieën behouden, de koppeling loslaten',
'docsync.deleteTrash': 'De andere kopie naar de prullenbak verplaatsen',
'docsync.deleteUnlink': 'Beide kopieën behouden',
'docsync.deleteTrash': 'Naar de prullenbak verplaatsen',
'docsync.webhookHint':
'Plak deze URL bij je aanbieder, dan komen wijzigingen meteen binnen. Zonder die URL kijkt TREK op vaste tijden.',
@@ -97,8 +91,82 @@ const docsync: TranslationStrings = {
'docsync.error.timeout': 'De aanbieder deed er te lang over om te antwoorden.',
'docsync.error.ssrf_blocked': 'Dat adres is niet toegestaan.',
'docsync.error.mass_delete_guard':
'Bijna alle documenten verdwenen in één keer, dus er is niets gewijzigd. Controleer of de map nog gekoppeld is.',
'De meeste documenten verdwenen in één keer, dus er is niets gewijzigd. Controleer of de map nog gekoppeld is.',
'docsync.error.unknown': 'Er ging iets mis.',
// ── Het venster ────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Deze reis',
'docsync.addAnother': 'Nog een toevoegen',
'docsync.syncing': 'Bezig met synchroniseren',
'docsync.card.pickFolder': 'Verbonden, kies een map',
'docsync.empty.title': 'Nog niets verbonden',
'docsync.empty.hintOwner':
'Kies links een opslag. TREK houdt van alles een eigen kopie, dus er gaat niets verloren als die wegvalt.',
'docsync.empty.hintMember': 'De eigenaar van de reis stelt dit in. Documenten blijven hoe dan ook in TREK.',
// Hoe elk product dingen opbergt. Staat er al voordat iemand verbindt, want het
// is wat het volgende scherm gaat vragen.
'docsync.model.paperless': 'Ordent op tag',
'docsync.model.papra': 'Ordent op tag, binnen een organisatie',
'docsync.model.nextcloud': 'Ordent in een map',
'docsync.model.opencloud': 'Ordent in een ruimte',
'docsync.model.synologydrive': 'Ordent in een map op de NAS',
// ── De stroombalk ──────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Naar de opslag',
'docsync.flow.toTrek': 'Uit de opslag',
'docsync.flow.documents': 'documenten',
'docsync.flow.summary.both': 'Documenten gaan beide kanten op.',
'docsync.flow.summary.pull': 'Documenten komen alleen binnen.',
'docsync.flow.summary.push': 'Documenten gaan alleen naar buiten.',
'docsync.flow.summaryEditable.both': 'Gaat beide kanten op. Tik op een baan om die te stoppen.',
'docsync.flow.summaryEditable.pull': 'Komt alleen binnen. Tik op de andere baan om ook te versturen.',
'docsync.flow.summaryEditable.push': 'Gaat alleen naar buiten. Tik op de andere baan om ook op te halen.',
// ── Eén koppeling ──────────────────────────────────────────────────────────
'docsync.binding.settings': 'Instellingen',
'docsync.binding.folder': 'Map',
'docsync.binding.lastRun': 'Laatst uitgevoerd',
'docsync.binding.autoOff': 'Gepauzeerd',
'docsync.binding.neverRun': 'nog niet uitgevoerd',
'docsync.binding.deleteHint': 'Wat er met de kopie aan de andere kant gebeurt.',
'docsync.binding.autoHint': 'Op de achtergrond op wijzigingen controleren.',
'docsync.binding.webhookTitle': 'Directe updates',
'docsync.binding.copy': 'Kopiëren',
'docsync.binding.copied': 'Gekopieerd',
// ── Verbinden ──────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Verbinden',
'docsync.connect.testing': 'Bezig met verbinden',
'docsync.connect.okAs': 'Bereikt, aangemeld als {account}',
'docsync.connect.insecureHint': 'Voor een instantie op je eigen netwerk met een zelfondertekend certificaat.',
'docsync.connect.about.paperless': 'TREK zet deze reis onder een eigen tag en komt nooit aan de rest van je archief.',
'docsync.connect.about.papra':
'Kies de organisatie waar deze reis bij hoort. TREK zet hem daarbinnen onder een eigen tag.',
'docsync.connect.about.nextcloud':
'Gebruik een app-wachtwoord, niet je accountwachtwoord: het blijft werken met tweestapsverificatie en je kunt het los intrekken.',
'docsync.connect.about.opencloud': 'TREK krijgt een eigen ruimte voor deze reis, los van al het andere.',
'docsync.connect.about.synologydrive': 'Het beste een DSM-account dat alleen bij de gedeelde map van deze reis kan.',
// ── De plek kiezen ─────────────────────────────────────────────────────────
'docsync.scope.title': 'Waar moet deze reis in {provider} komen te staan?',
'docsync.scope.intro': 'Alleen wat hierin staat wordt gesynchroniseerd. De rest van je opslag blijft buiten TREK.',
'docsync.scope.createTitle': 'Een nieuwe maken',
'docsync.scope.createAction': 'Aanmaken',
'docsync.scope.pickTitle': 'Of gebruik er een die je al hebt',
'docsync.scope.search': 'Zoeken',
'docsync.scope.noMatch': 'Niets komt daarmee overeen.',
// ── Dingen waar iemand over moet beslissen ─────────────────────────────────
'docsync.issues.title': 'Vraagt om aandacht',
'docsync.issues.conflict': 'Op beide plekken gewijzigd. Kies welke je behoudt.',
'docsync.issues.remote_missing': 'Weg uit de opslag. De kopie in TREK staat er nog.',
'docsync.issues.rejected_type': 'Dit bestandstype is hier niet toegestaan.',
'docsync.issues.too_large': 'Groter dan de limiet.',
'docsync.issues.error': 'De overdracht is niet gelukt.',
'docsync.error.unknown_provider': 'Deze aanbieder is niet beschikbaar op deze instantie.',
};
+80 -10
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Synchronizacja dokumentów',
'docsync.subtitle': 'Utrzymuj dokumenty tej podróży w zgodzie z własnym repozytorium dokumentów.',
'docsync.noProviders': 'Brak dostępnych dostawców dokumentów',
'docsync.noProvidersHint': 'Administrator instancji włącza ich w sekcji Administracja → Dodatki → Dokumenty.',
'docsync.addProvider': 'Połącz dostawcę',
'docsync.test': 'Testuj połączenie',
'docsync.connect.optional': 'Opcjonalne',
'docsync.connected': 'Połączono',
'docsync.chooseFolder': 'Wybierz folder',
'docsync.chooseFolderHint':
'Wskaż folder, tag lub przestrzeń należącą do tej podróży. Synchronizowane są tylko dokumenty, które się w niej znajdują.',
'docsync.noFolders': 'Na tej instancji nic jeszcze nie znaleziono.',
'docsync.newFolderPlaceholder': 'Nazwa nowego folderu',
'docsync.createFolder': 'Utwórz',
'docsync.syncNow': 'Synchronizuj teraz',
'docsync.unlink': 'Rozłącz',
'docsync.confirmUnlink': 'Dokumenty zostają w TREK i w repozytorium. Znika tylko powiązanie między nimi.',
'docsync.syncEnabled': 'Synchronizuj automatycznie',
'docsync.direction': 'Kierunek',
'docsync.directionBoth': 'W obie strony',
'docsync.directionPull': 'Tylko do TREK',
'docsync.directionPush': 'Tylko do dostawcy',
'docsync.deletePolicy': 'Gdy dokument zostanie usunięty',
'docsync.deleteUnlink': 'Zachowaj obie kopie, usuń powiązanie',
'docsync.deleteTrash': 'Przenieś drugą kopię do jej kosza',
'docsync.deleteUnlink': 'Zachowaj obie kopie',
'docsync.deleteTrash': 'Przenieś do kosza',
'docsync.webhookHint':
'Wklej ten adres URL u swojego dostawcy, aby zmiany docierały natychmiast. Bez tego TREK sprawdza je co jakiś czas.',
@@ -99,6 +93,82 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Naraz zniknęła większość dokumentów, więc nic nie zostało zmienione. Sprawdź, czy folder jest nadal podłączony.',
'docsync.error.unknown': 'Coś poszło nie tak.',
// ── Okno dialogowe ─────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Ta podróż',
'docsync.addAnother': 'Dodaj kolejne',
'docsync.syncing': 'Synchronizowanie',
'docsync.card.pickFolder': 'Połączono, wybierz folder',
'docsync.empty.title': 'Nic jeszcze nie połączono',
'docsync.empty.hintOwner':
'Wybierz repozytorium po lewej. TREK trzyma własną kopię wszystkiego, więc nic nie przepadnie, jeśli ono zniknie.',
'docsync.empty.hintMember': 'Konfiguruje to właściciel podróży. Tak czy inaczej dokumenty zostają w TREK.',
// Jak każdy produkt porządkuje pliki. Pokazywane, zanim ktokolwiek się połączy,
// bo o to zapyta następny ekran.
'docsync.model.paperless': 'Porządkuje pliki tagami',
'docsync.model.papra': 'Porządkuje pliki tagami, w obrębie organizacji',
'docsync.model.nextcloud': 'Przechowuje pliki w folderze',
'docsync.model.opencloud': 'Przechowuje pliki w przestrzeni',
'docsync.model.synologydrive': 'Przechowuje pliki w folderze na NAS-ie',
// ── Pasek przepływu ────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Do repozytorium',
'docsync.flow.toTrek': 'Z repozytorium',
'docsync.flow.documents': 'dokumentów',
'docsync.flow.summary.both': 'Dokumenty krążą w obie strony.',
'docsync.flow.summary.pull': 'Dokumenty tylko przychodzą.',
'docsync.flow.summary.push': 'Dokumenty tylko wychodzą.',
'docsync.flow.summaryEditable.both': 'W obie strony. Dotknij pasa, aby go zatrzymać.',
'docsync.flow.summaryEditable.pull': 'Tylko przychodzą. Dotknij drugiego pasa, aby też wysyłać.',
'docsync.flow.summaryEditable.push': 'Tylko wychodzą. Dotknij drugiego pasa, aby też pobierać.',
// ── Jedno powiązanie ───────────────────────────────────────────────────────
'docsync.binding.settings': 'Ustawienia',
'docsync.binding.folder': 'Folder',
'docsync.binding.lastRun': 'Ostatnie uruchomienie',
'docsync.binding.autoOff': 'Wstrzymane',
'docsync.binding.neverRun': 'jeszcze nie uruchomiono',
'docsync.binding.deleteHint': 'Co dzieje się z kopią po drugiej stronie.',
'docsync.binding.autoHint': 'Sprawdzaj zmiany w tle.',
'docsync.binding.webhookTitle': 'Natychmiastowe aktualizacje',
'docsync.binding.copy': 'Kopiuj',
'docsync.binding.copied': 'Skopiowano',
// ── Łączenie ───────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Połącz',
'docsync.connect.testing': 'Próba połączenia',
'docsync.connect.okAs': 'Połączono, zalogowano jako {account}',
'docsync.connect.insecureHint': 'Dla instancji w Twojej sieci z certyfikatem z własnym podpisem.',
'docsync.connect.about.paperless': 'TREK zapisuje tę podróż pod własnym tagiem i nie rusza reszty Twojego archiwum.',
'docsync.connect.about.papra':
'Wybierz organizację, do której należy ta podróż. TREK zapisze ją w niej pod własnym tagiem.',
'docsync.connect.about.nextcloud':
'Użyj hasła aplikacji, nie hasła do konta: działa mimo logowania dwuskładnikowego i możesz je unieważnić osobno.',
'docsync.connect.about.opencloud': 'TREK dostaje własną przestrzeń dla tej podróży, oddzieloną od reszty.',
'docsync.connect.about.synologydrive':
'Najlepiej konto DSM sięgające tylko do folderu współdzielonego przeznaczonego dla tej podróży.',
// ── Wybór miejsca ──────────────────────────────────────────────────────────
'docsync.scope.title': 'Gdzie ta podróż ma się znaleźć w {provider}?',
'docsync.scope.intro':
'Synchronizowane jest tylko to, co jest w środku. Reszta Twojego repozytorium zostaje poza TREK.',
'docsync.scope.createTitle': 'Utwórz nowe miejsce',
'docsync.scope.createAction': 'Utwórz',
'docsync.scope.pickTitle': 'Albo użyj istniejącego',
'docsync.scope.search': 'Szukaj',
'docsync.scope.noMatch': 'Nic nie pasuje.',
// ── Rzeczy, o których musi zdecydować człowiek ─────────────────────────────
'docsync.issues.title': 'Wymaga uwagi',
'docsync.issues.conflict': 'Zmieniony w obu miejscach. Wybierz, którą wersję zachować.',
'docsync.issues.remote_missing': 'Zniknął z repozytorium. Kopia w TREK nadal tu jest.',
'docsync.issues.rejected_type': 'Ten typ pliku jest tu niedozwolony.',
'docsync.issues.too_large': 'Większy niż limit.',
'docsync.issues.error': 'Transfer się nie powiódł.',
'docsync.error.unknown_provider': 'Ten dostawca nie jest dostępny na tej instancji.',
};
+80 -10
View File
@@ -2,29 +2,23 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Синхронизация документов',
'docsync.subtitle': 'Держите документы этой поездки в одном состоянии с вашим собственным хранилищем документов.',
'docsync.noProviders': 'Нет доступных провайдеров документов',
'docsync.noProvidersHint':
'Администратор экземпляра включает их в разделе «Администрирование → Дополнения → Документы».',
'docsync.addProvider': 'Подключить провайдера',
'docsync.test': 'Проверить подключение',
'docsync.connect.optional': 'Необязательно',
'docsync.connected': 'Подключено',
'docsync.chooseFolder': 'Выбрать папку',
'docsync.chooseFolderHint':
'Выберите папку, метку или пространство, которые относятся к этой поездке. Синхронизируются только документы из них.',
'docsync.noFolders': 'В этом экземпляре пока ничего не найдено.',
'docsync.newFolderPlaceholder': 'Название новой папки',
'docsync.createFolder': 'Создать',
'docsync.syncNow': 'Синхронизировать сейчас',
'docsync.unlink': 'Отключить',
'docsync.confirmUnlink': 'Документы останутся и в TREK, и в хранилище. Исчезнет только связь между ними.',
'docsync.syncEnabled': 'Синхронизировать автоматически',
'docsync.direction': 'Направление',
'docsync.directionBoth': 'В обе стороны',
'docsync.directionPull': 'Только в TREK',
'docsync.directionPush': 'Только к провайдеру',
'docsync.deletePolicy': 'Когда документ удалён',
'docsync.deleteUnlink': 'Оставить обе копии, убрать связь',
'docsync.deleteTrash': 'Переместить вторую копию в её корзину',
'docsync.deleteUnlink': 'Оставить обе копии',
'docsync.deleteTrash': 'Переместить в корзину',
'docsync.webhookHint':
'Вставьте этот URL у своего провайдера, чтобы изменения приходили сразу. Без него TREK проверяет по таймеру.',
@@ -100,6 +94,82 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Сразу исчезло большинство документов, поэтому ничего не изменено. Проверьте, что папка всё ещё подключена.',
'docsync.error.unknown': 'Что-то пошло не так.',
// ── Диалог ─────────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Эта поездка',
'docsync.addAnother': 'Добавить ещё',
'docsync.syncing': 'Синхронизация',
'docsync.card.pickFolder': 'Подключено, выберите папку',
'docsync.empty.title': 'Пока ничего не подключено',
'docsync.empty.hintOwner':
'Выберите хранилище слева. TREK держит собственную копию всего, поэтому ничего не пропадёт, если оно исчезнет.',
'docsync.empty.hintMember': 'Это настраивает владелец поездки. Документы в любом случае остаются в TREK.',
// Как каждый продукт раскладывает файлы. Показывается до подключения, потому
// что именно об этом спросит следующий экран.
'docsync.model.paperless': 'Хранит по меткам',
'docsync.model.papra': 'Хранит по меткам внутри организации',
'docsync.model.nextcloud': 'Хранит в папке',
'docsync.model.opencloud': 'Хранит в пространстве',
'docsync.model.synologydrive': 'Хранит в папке на NAS',
// ── Полоса потока ──────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'В хранилище',
'docsync.flow.toTrek': 'Из хранилища',
'docsync.flow.documents': 'документов',
'docsync.flow.summary.both': 'Документы идут в обе стороны.',
'docsync.flow.summary.pull': 'Документы только приходят.',
'docsync.flow.summary.push': 'Документы только уходят.',
'docsync.flow.summaryEditable.both': 'Идут в обе стороны. Нажмите полосу, чтобы её остановить.',
'docsync.flow.summaryEditable.pull': 'Только приходят. Нажмите вторую полосу, чтобы и отправлять.',
'docsync.flow.summaryEditable.push': 'Только уходят. Нажмите вторую полосу, чтобы и получать.',
// ── Одна связь ─────────────────────────────────────────────────────────────
'docsync.binding.settings': 'Настройки',
'docsync.binding.folder': 'Папка',
'docsync.binding.lastRun': 'Последний запуск',
'docsync.binding.autoOff': 'Приостановлено',
'docsync.binding.neverRun': 'ещё не запускалась',
'docsync.binding.deleteHint': 'Что происходит с копией на другой стороне.',
'docsync.binding.autoHint': 'Проверять изменения в фоне.',
'docsync.binding.webhookTitle': 'Мгновенные обновления',
'docsync.binding.copy': 'Копировать',
'docsync.binding.copied': 'Скопировано',
// ── Подключение ────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Подключить',
'docsync.connect.testing': 'Пробуем связаться',
'docsync.connect.okAs': 'Связь есть, вход выполнен как {account}',
'docsync.connect.insecureHint': 'Для экземпляра в вашей сети с самоподписанным сертификатом.',
'docsync.connect.about.paperless': 'TREK держит эту поездку под собственной меткой и не трогает остальной архив.',
'docsync.connect.about.papra':
'Выберите организацию, к которой относится поездка. Внутри неё TREK держит её под собственной меткой.',
'docsync.connect.about.nextcloud':
'Используйте пароль приложения, а не пароль учётной записи: он переживает двухфакторную проверку, и его можно отозвать отдельно.',
'docsync.connect.about.opencloud':
'Для этой поездки TREK получает собственное пространство, отдельно от всего остального.',
'docsync.connect.about.synologydrive':
'Лучше учётная запись DSM, у которой есть доступ только к общей папке этой поездки.',
// ── Выбор контейнера ───────────────────────────────────────────────────────
'docsync.scope.title': 'Где эта поездка будет лежать в {provider}?',
'docsync.scope.intro': 'Синхронизируется только то, что лежит здесь. Всё остальное в хранилище в TREK не попадёт.',
'docsync.scope.createTitle': 'Создать с нуля',
'docsync.scope.createAction': 'Создать',
'docsync.scope.pickTitle': 'Или выбрать из существующих',
'docsync.scope.search': 'Поиск',
'docsync.scope.noMatch': 'Ничего не найдено.',
// ── То, что должен решить человек ──────────────────────────────────────────
'docsync.issues.title': 'Требует внимания',
'docsync.issues.conflict': 'Изменено с обеих сторон. Выберите, какую копию оставить.',
'docsync.issues.remote_missing': 'Пропало из хранилища. Копия в TREK на месте.',
'docsync.issues.rejected_type': 'Такой тип файла здесь не разрешён.',
'docsync.issues.too_large': 'Больше допустимого размера.',
'docsync.issues.error': 'Передача не прошла.',
'docsync.error.unknown_provider': 'Этот провайдер недоступен в этом экземпляре.',
};
+78 -10
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Dokumentsynkronisering',
'docsync.subtitle': 'Håll resans dokument i takt med ditt eget dokumentarkiv.',
'docsync.noProviders': 'Inga dokumentleverantörer är tillgängliga',
'docsync.noProvidersHint': 'En administratör för instansen slår på dem under Administration, Tillägg, Dokument.',
'docsync.addProvider': 'Anslut en leverantör',
'docsync.test': 'Testa anslutningen',
'docsync.connect.optional': 'Valfritt',
'docsync.connected': 'Ansluten',
'docsync.chooseFolder': 'Välj mapp',
'docsync.chooseFolderHint':
'Välj den mapp, tagg eller yta som hör till den här resan. Bara dokument i den synkroniseras.',
'docsync.noFolders': 'Ingenting hittades på den här instansen än.',
'docsync.newFolderPlaceholder': 'Namn på ny mapp',
'docsync.createFolder': 'Skapa',
'docsync.syncNow': 'Synkronisera nu',
'docsync.unlink': 'Koppla från',
'docsync.confirmUnlink': 'Dokumenten finns kvar i TREK och i arkivet. Bara kopplingen mellan dem försvinner.',
'docsync.syncEnabled': 'Synkronisera automatiskt',
'docsync.direction': 'Riktning',
'docsync.directionBoth': 'Åt båda hållen',
'docsync.directionPull': 'Bara in till TREK',
'docsync.directionPush': 'Bara ut till leverantören',
'docsync.deletePolicy': 'När ett dokument tas bort',
'docsync.deleteUnlink': 'Behåll båda kopiorna, släpp kopplingen',
'docsync.deleteTrash': 'Flytta den andra kopian till papperskorgen',
'docsync.deleteUnlink': 'Behåll båda kopiorna',
'docsync.deleteTrash': 'Flytta till papperskorgen',
'docsync.webhookHint':
'Klistra in den här URL:en hos din leverantör så kommer ändringar direkt. Utan den kontrollerar TREK med jämna mellanrum.',
@@ -96,6 +90,80 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Nästan alla dokument försvann på en gång, så ingenting ändrades. Kontrollera att mappen fortfarande är monterad.',
'docsync.error.unknown': 'Något gick fel.',
// ── Dialogen ───────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Den här resan',
'docsync.addAnother': 'Lägg till en till',
'docsync.syncing': 'Synkroniserar',
'docsync.card.pickFolder': 'Ansluten, välj en mapp',
'docsync.empty.title': 'Inget anslutet än',
'docsync.empty.hintOwner':
'Välj ett arkiv till vänster. TREK behåller en egen kopia av allt, så inget går förlorat om arkivet försvinner.',
'docsync.empty.hintMember': 'Resans ägare ställer in det här. Dokumenten finns kvar i TREK oavsett.',
// Hur varje produkt sorterar sina filer. Visas innan någon ansluter, eftersom
// det är vad nästa steg kommer att fråga efter.
'docsync.model.paperless': 'Sorterar efter tagg',
'docsync.model.papra': 'Sorterar efter tagg, inom en organisation',
'docsync.model.nextcloud': 'Sorterar i en mapp',
'docsync.model.opencloud': 'Sorterar i en yta',
'docsync.model.synologydrive': 'Sorterar i en mapp på NAS:en',
// ── Flödesraden ────────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Ut till arkivet',
'docsync.flow.toTrek': 'In från arkivet',
'docsync.flow.documents': 'dokument',
'docsync.flow.summary.both': 'Dokument går åt båda hållen.',
'docsync.flow.summary.pull': 'Dokument kommer bara in.',
'docsync.flow.summary.push': 'Dokument går bara ut.',
'docsync.flow.summaryEditable.both': 'Går åt båda hållen. Tryck på ett spår för att stoppa det.',
'docsync.flow.summaryEditable.pull': 'Kommer bara in. Tryck på andra spåret för att skicka ut också.',
'docsync.flow.summaryEditable.push': 'Går bara ut. Tryck på andra spåret för att hämta in också.',
// ── En koppling ────────────────────────────────────────────────────────────
'docsync.binding.settings': 'Inställningar',
'docsync.binding.folder': 'Mapp',
'docsync.binding.lastRun': 'Senaste körning',
'docsync.binding.autoOff': 'Pausad',
'docsync.binding.neverRun': 'inte körd än',
'docsync.binding.deleteHint': 'Vad som händer med kopian på andra sidan.',
'docsync.binding.autoHint': 'Leta efter ändringar i bakgrunden.',
'docsync.binding.webhookTitle': 'Direkta uppdateringar',
'docsync.binding.copy': 'Kopiera',
'docsync.binding.copied': 'Kopierad',
// ── Ansluta ────────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Anslut',
'docsync.connect.testing': 'Försöker nå den',
'docsync.connect.okAs': 'Nådde den, inloggad som {account}',
'docsync.connect.insecureHint': 'För en instans i ditt eget nätverk med ett självsignerat certifikat.',
'docsync.connect.about.paperless':
'TREK lägger den här resan under en egen tagg och rör aldrig resten av ditt arkiv.',
'docsync.connect.about.papra': 'Välj organisationen som resan hör till. TREK lägger den under en egen tagg där inne.',
'docsync.connect.about.nextcloud':
'Använd ett applösenord, inte ditt kontolösenord: det klarar tvåfaktor och kan återkallas för sig.',
'docsync.connect.about.opencloud': 'TREK får en egen yta för den här resan, skild från allt annat.',
'docsync.connect.about.synologydrive': 'Helst ett DSM-konto som bara når den delade mapp resan ska använda.',
// ── Välja behållaren ───────────────────────────────────────────────────────
'docsync.scope.title': 'Var ska den här resan ligga i {provider}?',
'docsync.scope.intro': 'Bara det som ligger här synkroniseras. Allt annat i ditt arkiv hålls utanför TREK.',
'docsync.scope.createTitle': 'Skapa en ny',
'docsync.scope.createAction': 'Skapa',
'docsync.scope.pickTitle': 'Eller använd en du redan har',
'docsync.scope.search': 'Sök',
'docsync.scope.noMatch': 'Inget matchar det.',
// ── Sådant någon måste ta ställning till ───────────────────────────────────
'docsync.issues.title': 'Behöver ses över',
'docsync.issues.conflict': 'Ändrad på båda ställena. Välj vilken som ska behållas.',
'docsync.issues.remote_missing': 'Borta från arkivet. TREK-kopian finns kvar.',
'docsync.issues.rejected_type': 'Den här filtypen tillåts inte här.',
'docsync.issues.too_large': 'Större än gränsen.',
'docsync.issues.error': 'Överföringen gick inte igenom.',
'docsync.error.unknown_provider': 'Den här leverantören är inte tillgänglig på den här instansen.',
};
+82 -13
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Belge eşitleme',
'docsync.subtitle': 'Bu gezinin belgelerini kendi belge deponuzla aynı durumda tutun.',
'docsync.noProviders': 'Kullanılabilir belge sağlayıcısı yok',
'docsync.noProvidersHint': 'Bunları bir kurulum yöneticisi Yönetim → Eklentiler → Belgeler altından açar.',
'docsync.noProvidersHint': 'Bunları bir kurulum yöneticisi Yönetim, Eklentiler, Belgeler altından açar.',
'docsync.addProvider': 'Sağlayıcı bağla',
'docsync.test': 'Bağlantıyı test et',
'docsync.connect.optional': 'İsteğe bağlı',
'docsync.connected': 'Bağlandı',
'docsync.chooseFolder': 'Klasör seç',
'docsync.chooseFolderHint':
'Bu geziye ait klasörü, etiketi veya alanı seçin. Yalnızca onun içindeki belgeler eşitlenir.',
'docsync.noFolders': 'Bu kurulumda henüz bir şey bulunamadı.',
'docsync.newFolderPlaceholder': 'Yeni klasör adı',
'docsync.createFolder': 'Oluştur',
'docsync.syncNow': 'Şimdi eşitle',
'docsync.unlink': 'Bağlantıyı kes',
'docsync.confirmUnlink': 'Belgeler hem TREK’te hem depoda kalır. Yalnızca aralarındaki eşleştirme kaldırılır.',
'docsync.syncEnabled': 'Otomatik eşitle',
'docsync.direction': 'Yön',
'docsync.directionBoth': 'Her iki yönde',
'docsync.directionPull': 'Yalnızca TREK’e',
'docsync.directionPush': 'Yalnızca sağlayıcıya',
'docsync.deletePolicy': 'Bir belge silindiğinde',
'docsync.deleteUnlink': 'İki kopyayı da tut, eşleştirmeyi kaldır',
'docsync.deleteTrash': 'Diğer kopyayı kendi çöp kutusuna taşı',
'docsync.deleteUnlink': 'İki kopyayı da tut',
'docsync.deleteTrash': 'Geri dönüşüm kutusuna taşı',
'docsync.webhookHint':
'Değişikliklerin hemen ulaşması için bu URL’yi sağlayıcınıza yapıştırın. Bu olmadan TREK belirli aralıklarla denetler.',
@@ -46,8 +40,7 @@ const docsync: TranslationStrings = {
'Papra’da API anahtarları altında oluşturun. Papra anahtarları her zaman üyesi olduğunuz bütün kuruluşlara erişir.',
'docsync.hintPapraOrg': 'Papra adres çubuğundaki org_… kimliği.',
'docsync.hintNextcloudLogin': 'Nextcloud oturum açma adınız, e-posta adresiniz değil.',
'docsync.hintNextcloudAppPassword':
'Ayarlar → Güvenlik → Yeni uygulama parolası oluştur. Asla hesap parolanız değil.',
'docsync.hintNextcloudAppPassword': 'Ayarlar, Güvenlik, Yeni uygulama parolası oluştur. Asla hesap parolanız değil.',
'docsync.hintOpenCloudToken': 'OpenCloud’da uygulama belirteçleri altında oluşturulur.',
'docsync.hintBasePath': 'TREK’in gezi klasörlerini aradığı yer. Varsayılan /TREK.',
'docsync.hintSynologyUrl': 'Bağlantı noktasını da yazın, örneğin https://nas.example.com:5001',
@@ -99,6 +92,82 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Belgelerin çoğu bir anda kayboldu, bu yüzden hiçbir şey değiştirilmedi. Klasörün hâlâ bağlı olduğunu denetleyin.',
'docsync.error.unknown': 'Bir şeyler ters gitti.',
// ── İletişim kutusu ────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Bu gezi',
'docsync.addAnother': 'Başka ekle',
'docsync.syncing': 'Eşitleniyor',
'docsync.card.pickFolder': 'Bağlandı, bir klasör seçin',
'docsync.empty.title': 'Henüz bir şey bağlanmadı',
'docsync.empty.hintOwner':
'Soldan bir depo seçin. TREK her şeyin kendi kopyasını tutar, depo ortadan kalksa da hiçbir şey kaybolmaz.',
'docsync.empty.hintMember': 'Bunu gezi sahibi ayarlar. Belgeler her durumda TREK’te kalır.',
// Her ürünün dosyalama biçimi. Kimse bağlanmadan önce gösterilir, çünkü bir
// sonraki ekranın soracağı şey budur.
'docsync.model.paperless': 'Etiketlerle düzenler',
'docsync.model.papra': 'Bir kuruluş içinde etiketlerle düzenler',
'docsync.model.nextcloud': 'Bir klasörde tutar',
'docsync.model.opencloud': 'Bir alanda tutar',
'docsync.model.synologydrive': 'NAS’taki bir klasörde tutar',
// ── Akış çubuğu ────────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Depoya giden',
'docsync.flow.toTrek': 'Depodan gelen',
'docsync.flow.documents': 'belge',
'docsync.flow.summary.both': 'Belgeler iki yönde de taşınır.',
'docsync.flow.summary.pull': 'Belgeler yalnızca içeri gelir.',
'docsync.flow.summary.push': 'Belgeler yalnızca dışarı gider.',
'docsync.flow.summaryEditable.both': 'İki yönde de taşınıyor. Durdurmak için bir şeride dokunun.',
'docsync.flow.summaryEditable.pull': 'Yalnızca içeri geliyor. Dışarı da göndermek için diğer şeride dokunun.',
'docsync.flow.summaryEditable.push': 'Yalnızca dışarı gidiyor. İçeri de almak için diğer şeride dokunun.',
// ── Tek bir bağ ────────────────────────────────────────────────────────────
'docsync.binding.settings': 'Ayarlar',
'docsync.binding.folder': 'Klasör',
'docsync.binding.lastRun': 'Son çalışma',
'docsync.binding.autoOff': 'Duraklatıldı',
'docsync.binding.neverRun': 'henüz çalışmadı',
'docsync.binding.deleteHint': 'Karşı taraftaki kopyaya ne olacağı.',
'docsync.binding.autoHint': 'Değişiklikleri arka planda denetler.',
'docsync.binding.webhookTitle': 'Anında güncelleme',
'docsync.binding.copy': 'Kopyala',
'docsync.binding.copied': 'Kopyalandı',
// ── Bağlanma ───────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Bağlan',
'docsync.connect.testing': 'Ulaşılmaya çalışılıyor',
'docsync.connect.okAs': 'Ulaşıldı, {account} olarak oturum açıldı',
'docsync.connect.insecureHint': 'Kendi ağınızdaki, kendinden imzalı sertifikası olan kurulumlar için.',
'docsync.connect.about.paperless':
'TREK bu geziyi kendi etiketi altında tutar, arşivinizin geri kalanına hiç dokunmaz.',
'docsync.connect.about.papra':
'Bu gezinin ait olduğu kuruluşu seçin. TREK geziyi onun içinde kendi etiketi altında tutar.',
'docsync.connect.about.nextcloud':
'Hesap parolanızı değil, bir uygulama parolası kullanın: iki adımlı doğrulamayla çalışır ve tek başına iptal edilebilir.',
'docsync.connect.about.opencloud': 'TREK bu gezi için her şeyden ayrı, kendi alanını alır.',
'docsync.connect.about.synologydrive':
'En iyisi, yalnızca bu gezinin kullanacağı paylaşılan klasöre erişen bir DSM hesabı.',
// ── Kapsayıcıyı seçme ──────────────────────────────────────────────────────
'docsync.scope.title': 'Bu gezi {provider} içinde nerede dursun?',
'docsync.scope.intro': 'Yalnızca buradakiler eşitlenir. Deponuzdaki diğer her şey TREK’in dışında kalır.',
'docsync.scope.createTitle': 'Yeni bir tane oluştur',
'docsync.scope.createAction': 'Oluştur',
'docsync.scope.pickTitle': 'Ya da var olanlardan birini kullanın',
'docsync.scope.search': 'Ara',
'docsync.scope.noMatch': 'Eşleşen bir şey yok.',
// ── Kişinin karar vermesi gerekenler ───────────────────────────────────────
'docsync.issues.title': 'Bakılması gerekenler',
'docsync.issues.conflict': 'İki yerde de değişti. Hangisinin kalacağını seçin.',
'docsync.issues.remote_missing': 'Depoda yok. TREK’teki kopya duruyor.',
'docsync.issues.rejected_type': 'Bu dosya türüne burada izin verilmiyor.',
'docsync.issues.too_large': 'Sınırdan büyük.',
'docsync.issues.error': 'Aktarım tamamlanamadı.',
'docsync.error.unknown_provider': 'Bu sağlayıcı bu kurulumda kullanılamıyor.',
};
+81 -12
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Синхронізація документів',
'docsync.subtitle': 'Тримайте документи цієї подорожі узгодженими з власним сховищем документів.',
'docsync.noProviders': 'Немає доступних постачальників документів',
'docsync.noProvidersHint': 'Адміністратор сервера вмикає їх у розділі Адміністрування → Доповнення → Документи.',
'docsync.noProvidersHint': 'Адміністратор сервера вмикає їх у розділі Адміністрування, Доповнення, Документи.',
'docsync.addProvider': 'Підключити постачальника',
'docsync.test': 'Перевірити з’єднання',
'docsync.connect.optional': 'Необов’язково',
'docsync.connected': 'Підключено',
'docsync.chooseFolder': 'Вибрати теку',
'docsync.chooseFolderHint':
'Оберіть теку, мітку або простір, що належить цій подорожі. Синхронізуються лише документи в ній.',
'docsync.noFolders': 'На цьому сервері поки нічого не знайдено.',
'docsync.newFolderPlaceholder': 'Назва нової теки',
'docsync.createFolder': 'Створити',
'docsync.syncNow': 'Синхронізувати зараз',
'docsync.unlink': 'Відключити',
'docsync.confirmUnlink': 'Документи залишаються і в TREK, і у сховищі. Зникає лише зв’язок між ними.',
'docsync.syncEnabled': 'Синхронізувати автоматично',
'docsync.direction': 'Напрямок',
'docsync.directionBoth': 'В обидва боки',
'docsync.directionPull': 'Лише в TREK',
'docsync.directionPush': 'Лише до постачальника',
'docsync.deletePolicy': 'Коли документ видалено',
'docsync.deleteUnlink': 'Зберегти обидві копії, розірвати зв’язок',
'docsync.deleteTrash': 'Перемістити другу копію до її кошика',
'docsync.deleteUnlink': 'Залишити обидві копії',
'docsync.deleteTrash': 'Перемістити до кошика',
'docsync.webhookHint':
'Вставте цю URL-адресу у свого постачальника, щоб зміни надходили одразу. Без цього TREK перевіряє їх за таймером.',
@@ -48,7 +42,7 @@ const docsync: TranslationStrings = {
'docsync.hintPapraOrg': 'Ідентифікатор org_… з адресного рядка Papra.',
'docsync.hintNextcloudLogin': 'Ваше ім’я для входу в Nextcloud, а не адреса електронної пошти.',
'docsync.hintNextcloudAppPassword':
'Налаштування → Безпека → Створити новий пароль додатка. Ніколи не пароль облікового запису.',
'Налаштування, Безпека, Створити новий пароль додатка. Ніколи не пароль облікового запису.',
'docsync.hintOpenCloudToken': 'Створюється в OpenCloud у розділі токенів додатків.',
'docsync.hintBasePath': 'Де TREK шукає теки подорожей. Типово /TREK.',
'docsync.hintSynologyUrl': 'Вкажіть і порт, наприклад https://nas.example.com:5001',
@@ -99,6 +93,81 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Більшість документів зникла одночасно, тому нічого не змінено. Перевірте, чи теку досі підключено.',
'docsync.error.unknown': 'Щось пішло не так.',
// ── Діалог ─────────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Ця подорож',
'docsync.addAnother': 'Додати ще',
'docsync.syncing': 'Синхронізація',
'docsync.card.pickFolder': 'Підключено, виберіть теку',
'docsync.empty.title': 'Ще нічого не підключено',
'docsync.empty.hintOwner':
'Виберіть сховище ліворуч. TREK зберігає власну копію всього, тож нічого не зникне, якщо сховища не стане.',
'docsync.empty.hintMember': 'Це налаштовує власник подорожі. Документи в будь-якому разі залишаються в TREK.',
// Як кожен продукт упорядковує файли. Показується ще до підключення, бо саме
// про це запитає наступний екран.
'docsync.model.paperless': 'Зберігає за мітками',
'docsync.model.papra': 'Зберігає за мітками в межах організації',
'docsync.model.nextcloud': 'Зберігає в теці',
'docsync.model.opencloud': 'Зберігає в просторі',
'docsync.model.synologydrive': 'Зберігає в теці на NAS',
// ── Смуга напрямків ────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'До сховища',
'docsync.flow.toTrek': 'Зі сховища',
'docsync.flow.documents': 'документів',
'docsync.flow.summary.both': 'Документи рухаються в обидва боки.',
'docsync.flow.summary.pull': 'Документи лише надходять.',
'docsync.flow.summary.push': 'Документи лише надсилаються.',
'docsync.flow.summaryEditable.both': 'Рух в обидва боки. Торкніться смуги, щоб зупинити її.',
'docsync.flow.summaryEditable.pull': 'Лише надходять. Торкніться іншої смуги, щоб також надсилати.',
'docsync.flow.summaryEditable.push': 'Лише надсилаються. Торкніться іншої смуги, щоб також отримувати.',
// ── Один зв’язок ───────────────────────────────────────────────────────────
'docsync.binding.settings': 'Налаштування',
'docsync.binding.folder': 'Тека',
'docsync.binding.lastRun': 'Останній запуск',
'docsync.binding.autoOff': 'Призупинено',
'docsync.binding.neverRun': 'ще не запускалося',
'docsync.binding.deleteHint': 'Що станеться з копією на іншому боці.',
'docsync.binding.autoHint': 'Перевіряти зміни у фоновому режимі.',
'docsync.binding.webhookTitle': 'Миттєві оновлення',
'docsync.binding.copy': 'Копіювати',
'docsync.binding.copied': 'Скопійовано',
// ── Підключення ────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Підключити',
'docsync.connect.testing': 'Пробуємо з’єднатися',
'docsync.connect.okAs': 'З’єднано, вхід як {account}',
'docsync.connect.insecureHint': 'Для сервера у вашій власній мережі із самопідписаним сертифікатом.',
'docsync.connect.about.paperless': 'TREK зберігає цю подорож під власною міткою і не чіпає решту вашого архіву.',
'docsync.connect.about.papra':
'Виберіть організацію, до якої належить ця подорож. TREK зберігає її там під власною міткою.',
'docsync.connect.about.nextcloud':
'Використовуйте пароль додатка, а не пароль облікового запису: він працює з двофакторною автентифікацією, і його можна відкликати окремо.',
'docsync.connect.about.opencloud': 'TREK отримує власний простір для цієї подорожі, окремо від усього іншого.',
'docsync.connect.about.synologydrive':
'Найкраще обліковий запис DSM, який має доступ лише до спільної теки для цієї подорожі.',
// ── Вибір місця зберігання ─────────────────────────────────────────────────
'docsync.scope.title': 'Де ця подорож має зберігатися в {provider}?',
'docsync.scope.intro': 'Синхронізується лише те, що всередині. Решта вашого сховища залишається поза TREK.',
'docsync.scope.createTitle': 'Створити нову',
'docsync.scope.createAction': 'Створити',
'docsync.scope.pickTitle': 'Або скористайтеся наявною',
'docsync.scope.search': 'Пошук',
'docsync.scope.noMatch': 'Нічого не знайдено.',
// ── Те, що має вирішити людина ─────────────────────────────────────────────
'docsync.issues.title': 'Потребує уваги',
'docsync.issues.conflict': 'Змінено в обох місцях. Виберіть, яку копію залишити.',
'docsync.issues.remote_missing': 'Зник зі сховища. Копія в TREK лишається.',
'docsync.issues.rejected_type': 'Цей тип файлу тут не дозволено.',
'docsync.issues.too_large': 'Більший за ліміт.',
'docsync.issues.error': 'Передавання не відбулося.',
'docsync.error.unknown_provider': 'Цей постачальник недоступний на цьому сервері.',
};
+80 -10
View File
@@ -2,28 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': 'Đồng bộ tài liệu',
'docsync.subtitle': 'Giữ tài liệu của chuyến đi này khớp với kho tài liệu của riêng bạn.',
'docsync.noProviders': 'Không có nhà cung cấp tài liệu nào khả dụng',
'docsync.noProvidersHint': 'Quản trị viên hệ thống bật chúng trong Quản trị → Tiện ích bổ sung → Tài liệu.',
'docsync.addProvider': 'Kết nối nhà cung cấp',
'docsync.test': 'Kiểm tra kết nối',
'docsync.connect.optional': 'Tùy chọn',
'docsync.connected': 'Đã kết nối',
'docsync.chooseFolder': 'Chọn thư mục',
'docsync.chooseFolderHint':
'Chọn thư mục, thẻ hoặc không gian thuộc về chuyến đi này. Chỉ tài liệu bên trong mới được đồng bộ.',
'docsync.noFolders': 'Chưa tìm thấy gì trên máy chủ này.',
'docsync.newFolderPlaceholder': 'Tên thư mục mới',
'docsync.createFolder': 'Tạo',
'docsync.syncNow': 'Đồng bộ ngay',
'docsync.unlink': 'Ngắt kết nối',
'docsync.confirmUnlink': 'Tài liệu vẫn ở lại trong TREK và trong kho. Chỉ có liên kết giữa chúng bị gỡ.',
'docsync.syncEnabled': 'Tự động đồng bộ',
'docsync.direction': 'Hướng',
'docsync.directionBoth': 'Cả hai chiều',
'docsync.directionPull': 'Chỉ vào TREK',
'docsync.directionPush': 'Chỉ ra nhà cung cấp',
'docsync.deletePolicy': 'Khi một tài liệu bị xóa',
'docsync.deleteUnlink': 'Giữ cả hai bản, bỏ ghép cặp',
'docsync.deleteTrash': 'Chuyển bản còn lại vào thùng rác của nó',
'docsync.deleteUnlink': 'Giữ cả hai bản',
'docsync.deleteTrash': 'Chuyển vào thùng rác',
'docsync.webhookHint':
'Dán URL này vào nhà cung cấp của bạn để thay đổi đến ngay lập tức. Nếu không, TREK sẽ kiểm tra theo định kỳ.',
@@ -97,6 +91,82 @@ const docsync: TranslationStrings = {
'docsync.error.mass_delete_guard':
'Phần lớn tài liệu biến mất cùng lúc, nên không có gì được thay đổi. Hãy kiểm tra xem thư mục còn được gắn kết không.',
'docsync.error.unknown': 'Đã xảy ra lỗi.',
// ── Hộp thoại ──────────────────────────────────────────────────────────────
'docsync.sidebar.connected': 'Chuyến đi này',
'docsync.addAnother': 'Thêm nữa',
'docsync.syncing': 'Đang đồng bộ',
'docsync.card.pickFolder': 'Đã kết nối, hãy chọn thư mục',
'docsync.empty.title': 'Chưa kết nối gì',
'docsync.empty.hintOwner':
'Hãy chọn một kho ở bên trái. TREK luôn giữ bản sao của riêng mình, nên không mất gì nếu kho đó biến mất.',
'docsync.empty.hintMember': 'Chủ chuyến đi là người thiết lập việc này. Dù sao thì tài liệu vẫn ở lại trong TREK.',
// Cách mỗi sản phẩm sắp xếp tài liệu. Hiển thị trước khi ai đó kết nối, vì đó
// chính là thứ màn hình kế tiếp sẽ hỏi.
'docsync.model.paperless': 'Lưu theo thẻ',
'docsync.model.papra': 'Lưu theo thẻ, bên trong một tổ chức',
'docsync.model.nextcloud': 'Lưu theo thư mục',
'docsync.model.opencloud': 'Lưu theo không gian',
'docsync.model.synologydrive': 'Lưu theo thư mục trên NAS',
// ── Thanh luồng ────────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': 'Gửi ra kho',
'docsync.flow.toTrek': 'Nhận từ kho',
'docsync.flow.documents': 'tài liệu',
'docsync.flow.summary.both': 'Tài liệu đi cả hai chiều.',
'docsync.flow.summary.pull': 'Tài liệu chỉ đi vào.',
'docsync.flow.summary.push': 'Tài liệu chỉ đi ra.',
'docsync.flow.summaryEditable.both': 'Đang đi cả hai chiều. Chạm một làn để dừng làn đó.',
'docsync.flow.summaryEditable.pull': 'Chỉ đi vào. Chạm làn kia để gửi ra nữa.',
'docsync.flow.summaryEditable.push': 'Chỉ đi ra. Chạm làn kia để nhận vào nữa.',
// ── Một liên kết ───────────────────────────────────────────────────────────
'docsync.binding.settings': 'Cài đặt',
'docsync.binding.folder': 'Thư mục',
'docsync.binding.lastRun': 'Lần chạy cuối',
'docsync.binding.autoOff': 'Đã tạm dừng',
'docsync.binding.neverRun': 'chưa chạy lần nào',
'docsync.binding.deleteHint': 'Điều sẽ xảy ra với bản sao ở phía bên kia.',
'docsync.binding.autoHint': 'Kiểm tra thay đổi ở chế độ nền.',
'docsync.binding.webhookTitle': 'Cập nhật tức thì',
'docsync.binding.copy': 'Sao chép',
'docsync.binding.copied': 'Đã sao chép',
// ── Kết nối ────────────────────────────────────────────────────────────────
'docsync.connect.submit': 'Kết nối',
'docsync.connect.testing': 'Đang thử kết nối',
'docsync.connect.okAs': 'Đã kết nối, đăng nhập với tư cách {account}',
'docsync.connect.insecureHint': 'Dành cho máy chủ trong mạng nội bộ của bạn dùng chứng chỉ tự ký.',
'docsync.connect.about.paperless':
'TREK lưu chuyến đi này dưới thẻ riêng của nó và không đụng tới phần còn lại trong kho của bạn.',
'docsync.connect.about.papra':
'Hãy chọn tổ chức mà chuyến đi này thuộc về. TREK sẽ lưu nó dưới thẻ riêng bên trong tổ chức đó.',
'docsync.connect.about.nextcloud':
'Hãy dùng mật khẩu ứng dụng, đừng dùng mật khẩu tài khoản: nó vẫn chạy được với xác thực hai yếu tố và bạn có thể thu hồi riêng.',
'docsync.connect.about.opencloud': 'TREK có không gian riêng cho chuyến đi này, tách khỏi mọi thứ khác.',
'docsync.connect.about.synologydrive':
'Tốt nhất là một tài khoản DSM chỉ truy cập được thư mục chia sẻ dành cho chuyến đi này.',
// ── Chọn nơi chứa ──────────────────────────────────────────────────────────
'docsync.scope.title': 'Chuyến đi này nên nằm ở đâu trong {provider}?',
'docsync.scope.intro': 'Chỉ những gì nằm trong đây mới được đồng bộ. Mọi thứ khác trong kho của bạn không vào TREK.',
'docsync.scope.createTitle': 'Tạo mục mới',
'docsync.scope.createAction': 'Tạo',
'docsync.scope.pickTitle': 'Hoặc dùng mục bạn đã có',
'docsync.scope.search': 'Tìm kiếm',
'docsync.scope.noMatch': 'Không có gì khớp.',
// ── Những việc cần người quyết định ────────────────────────────────────────
'docsync.issues.title': 'Cần xem lại',
'docsync.issues.conflict': 'Đã thay đổi ở cả hai nơi. Hãy chọn bản muốn giữ.',
'docsync.issues.remote_missing': 'Không còn trong kho. Bản trong TREK vẫn còn.',
'docsync.issues.rejected_type': 'Loại tệp này không được phép ở đây.',
'docsync.issues.too_large': 'Lớn hơn giới hạn cho phép.',
'docsync.issues.error': 'Việc truyền tệp không thành công.',
'docsync.error.unknown_provider': 'Nhà cung cấp này không khả dụng trên máy chủ này.',
};
+75 -9
View File
@@ -2,27 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': '文件同步',
'docsync.subtitle': '讓這趟旅行的文件與你自己的文件儲存庫保持同步。',
'docsync.noProviders': '沒有可用的文件提供者',
'docsync.noProvidersHint': '執行個體管理員可在「管理 → 擴充套件 → 文件」中啟用這些提供者。',
'docsync.addProvider': '連接提供者',
'docsync.test': '測試連線',
'docsync.connect.optional': '選填',
'docsync.connected': '已連線',
'docsync.chooseFolder': '選擇資料夾',
'docsync.chooseFolderHint': '挑選屬於這趟旅行的資料夾、標籤或空間。只有其中的文件會同步。',
'docsync.noFolders': '尚未在這個執行個體上找到任何項目。',
'docsync.newFolderPlaceholder': '新資料夾名稱',
'docsync.createFolder': '建立',
'docsync.syncNow': '立即同步',
'docsync.unlink': '中斷連線',
'docsync.confirmUnlink': '文件會留在 TREK,也會留在儲存庫,只有兩者之間的配對會解除。',
'docsync.syncEnabled': '自動同步',
'docsync.direction': '方向',
'docsync.directionBoth': '雙向',
'docsync.directionPull': '只同步進 TREK',
'docsync.directionPush': '只同步到提供者',
'docsync.deletePolicy': '當文件被刪除時',
'docsync.deleteUnlink': '兩邊都保留,只解除配對',
'docsync.deleteTrash': '把另一份移到它的回收站',
'docsync.deleteUnlink': '兩份都保留',
'docsync.deleteTrash': '移到回收桶',
'docsync.webhookHint': '把這個 URL 貼到你的提供者,變更就會立刻送達。沒有它,TREK 只會定時檢查。',
// 連線表單欄位。這些鍵對應 document_provider_fields 的 `label` 欄,
@@ -92,6 +87,77 @@ const docsync: TranslationStrings = {
'docsync.error.ssrf_blocked': '不允許這個位址。',
'docsync.error.mass_delete_guard': '大量文件同時消失,因此沒有做任何變更。請確認資料夾仍然掛載中。',
'docsync.error.unknown': '發生錯誤。',
// ── 對話框 ─────────────────────────────────────────────────────────────────
'docsync.sidebar.connected': '這趟旅行',
'docsync.addAnother': '再新增一個',
'docsync.syncing': '同步中',
'docsync.card.pickFolder': '已連線,請選擇資料夾',
'docsync.empty.title': '尚未連接任何項目',
'docsync.empty.hintOwner': '請在左邊挑一個儲存庫。TREK 會自己留一份副本,就算那邊消失了也不會遺失任何東西。',
'docsync.empty.hintMember': '這由旅行的擁有者設定。無論如何,文件都會留在 TREK。',
// 各產品的歸檔方式。在任何人連線之前就會顯示,
// 因為下一個畫面正是要問這些。
'docsync.model.paperless': '以標籤歸檔',
'docsync.model.papra': '在組織內以標籤歸檔',
'docsync.model.nextcloud': '歸檔在資料夾中',
'docsync.model.opencloud': '歸檔在空間中',
'docsync.model.synologydrive': '歸檔在 NAS 的資料夾中',
// ── 流向列 ─────────────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': '送往儲存庫',
'docsync.flow.toTrek': '來自儲存庫',
'docsync.flow.documents': '份文件',
'docsync.flow.summary.both': '文件雙向傳送。',
'docsync.flow.summary.pull': '文件只會傳入。',
'docsync.flow.summary.push': '文件只會傳出。',
'docsync.flow.summaryEditable.both': '正在雙向傳送。點一下任一方向可停用。',
'docsync.flow.summaryEditable.pull': '只會傳入。點另一個方向也可傳出。',
'docsync.flow.summaryEditable.push': '只會傳出。點另一個方向也可傳入。',
// ── 單一配對 ───────────────────────────────────────────────────────────────
'docsync.binding.settings': '設定',
'docsync.binding.folder': '資料夾',
'docsync.binding.lastRun': '上次執行',
'docsync.binding.autoOff': '已暫停',
'docsync.binding.neverRun': '尚未執行',
'docsync.binding.deleteHint': '另一邊那份副本會怎麼處理。',
'docsync.binding.autoHint': '在背景檢查是否有變更。',
'docsync.binding.webhookTitle': '即時更新',
'docsync.binding.copy': '複製',
'docsync.binding.copied': '已複製',
// ── 連線 ───────────────────────────────────────────────────────────────────
'docsync.connect.submit': '連接',
'docsync.connect.testing': '正在嘗試連線',
'docsync.connect.okAs': '已連上,登入身分為 {account}',
'docsync.connect.insecureHint': '適用於你自己網路上、使用自簽憑證的執行個體。',
'docsync.connect.about.paperless': 'TREK 會把這趟旅行歸在自己的標籤下,不會動到你封存的其他內容。',
'docsync.connect.about.papra': '選擇這趟旅行所屬的組織。TREK 會在其中以自己的標籤歸檔。',
'docsync.connect.about.nextcloud': '請用應用程式密碼,不要用帳戶密碼:它不受雙因素驗證影響,也可以單獨撤銷。',
'docsync.connect.about.opencloud': 'TREK 會為這趟旅行取得專屬的空間,與其他內容分開。',
'docsync.connect.about.synologydrive': '最好使用只能存取這趟旅行所用共用資料夾的 DSM 帳戶。',
// ── 挑選容器 ───────────────────────────────────────────────────────────────
'docsync.scope.title': '這趟旅行要放在 {provider} 的哪裡?',
'docsync.scope.intro': '只有放在這裡面的內容會同步。儲存庫裡的其他東西都不會進入 TREK。',
'docsync.scope.createTitle': '建立新的',
'docsync.scope.createAction': '建立',
'docsync.scope.pickTitle': '或使用你已經有的',
'docsync.scope.search': '搜尋',
'docsync.scope.noMatch': '沒有符合的項目。',
// ── 需要人來決定的事 ───────────────────────────────────────────────────────
'docsync.issues.title': '需要查看',
'docsync.issues.conflict': '兩邊都有變更。請選擇要保留哪一份。',
'docsync.issues.remote_missing': '已從儲存庫消失。TREK 的副本還在。',
'docsync.issues.rejected_type': '這裡不允許這種檔案類型。',
'docsync.issues.too_large': '超過大小限制。',
'docsync.issues.error': '傳輸沒有完成。',
'docsync.error.unknown_provider': '這個提供者在此執行個體上無法使用。',
};
+74 -9
View File
@@ -2,27 +2,22 @@ import type { TranslationStrings } from '../types';
const docsync: TranslationStrings = {
'docsync.title': '文档同步',
'docsync.subtitle': '让此行程的文档与你自己的文档存储保持一致。',
'docsync.noProviders': '暂无可用的文档服务',
'docsync.noProvidersHint': '实例管理员可在“管理 → 插件 → 文档”中启用。',
'docsync.addProvider': '连接文档服务',
'docsync.test': '测试连接',
'docsync.connect.optional': '可选',
'docsync.connected': '已连接',
'docsync.chooseFolder': '选择文件夹',
'docsync.chooseFolderHint': '选择属于此行程的文件夹、标签或空间。只有其中的文档会被同步。',
'docsync.noFolders': '此实例上暂未找到任何内容。',
'docsync.newFolderPlaceholder': '新文件夹名称',
'docsync.createFolder': '创建',
'docsync.syncNow': '立即同步',
'docsync.unlink': '断开连接',
'docsync.confirmUnlink': '文档会保留在 TREK 和文档服务中,消失的只是两者之间的配对。',
'docsync.syncEnabled': '自动同步',
'docsync.direction': '方向',
'docsync.directionBoth': '双向',
'docsync.directionPull': '仅同步到 TREK',
'docsync.directionPush': '仅同步到文档服务',
'docsync.deletePolicy': '当文档被删除时',
'docsync.deleteUnlink': '保留两份副本,只解除配对',
'docsync.deleteTrash': '把另一份副本移入其回收站',
'docsync.deleteUnlink': '两份副本都保留',
'docsync.deleteTrash': '移入回收站',
'docsync.webhookHint': '把此 URL 粘贴到你的文档服务中,变更就会立即送达。不设置时,TREK 会定时检查。',
// 连接表单的字段。这些键与 document_provider_fields 表的 `label` 列一一对应,
@@ -92,6 +87,76 @@ const docsync: TranslationStrings = {
'docsync.error.ssrf_blocked': '不允许使用该地址。',
'docsync.error.mass_delete_guard': '大量文档同时消失,因此未做任何改动。请检查该文件夹是否仍处于挂载状态。',
'docsync.error.unknown': '出了点问题。',
// ── 对话框 ────────────────────────────────────────────────────────────────
'docsync.sidebar.connected': '本次行程',
'docsync.addAnother': '再添加一个',
'docsync.syncing': '同步中',
'docsync.card.pickFolder': '已连接,请选择文件夹',
'docsync.empty.title': '尚未连接任何服务',
'docsync.empty.hintOwner': '在左侧挑一个文档服务。TREK 自己也留有一份副本,所以即使它不在了也不会丢东西。',
'docsync.empty.hintMember': '这由行程所有者来设置。无论如何,文档都会保留在 TREK 中。',
// 每个产品各自的归档方式。在连接之前就显示出来,因为下一屏问的正是这些。
'docsync.model.paperless': '按标签归档',
'docsync.model.papra': '在组织内按标签归档',
'docsync.model.nextcloud': '归档到文件夹',
'docsync.model.opencloud': '归档到空间',
'docsync.model.synologydrive': '归档到 NAS 上的文件夹',
// ── 流向条 ────────────────────────────────────────────────────────────────
'docsync.flow.trek': 'TREK',
'docsync.flow.toProvider': '发往文档服务',
'docsync.flow.toTrek': '来自文档服务',
'docsync.flow.documents': '文档',
'docsync.flow.summary.both': '文档双向同步。',
'docsync.flow.summary.pull': '文档只进不出。',
'docsync.flow.summary.push': '文档只出不进。',
'docsync.flow.summaryEditable.both': '正在双向同步。点按某一向即可停用。',
'docsync.flow.summaryEditable.pull': '只接收。点按另一向也可发送。',
'docsync.flow.summaryEditable.push': '只发送。点按另一向也可接收。',
// ── 单个配对 ──────────────────────────────────────────────────────────────
'docsync.binding.settings': '设置',
'docsync.binding.folder': '文件夹',
'docsync.binding.lastRun': '上次运行',
'docsync.binding.autoOff': '已暂停',
'docsync.binding.neverRun': '尚未运行',
'docsync.binding.deleteHint': '另一侧的副本会怎样。',
'docsync.binding.autoHint': '在后台检查变更。',
'docsync.binding.webhookTitle': '即时更新',
'docsync.binding.copy': '复制',
'docsync.binding.copied': '已复制',
// ── 连接 ──────────────────────────────────────────────────────────────────
'docsync.connect.submit': '连接',
'docsync.connect.testing': '正在尝试连接',
'docsync.connect.okAs': '已连接,登录身份为 {account}',
'docsync.connect.insecureHint': '适用于自有网络中使用自签名证书的实例。',
'docsync.connect.about.paperless': 'TREK 会用专属标签归档此行程,绝不碰你归档库里的其他内容。',
'docsync.connect.about.papra': '选择此行程所属的组织。TREK 会在其中用专属标签归档。',
'docsync.connect.about.nextcloud': '请使用应用密码,而不是账户密码:它不受两步验证影响,也可以单独吊销。',
'docsync.connect.about.opencloud': 'TREK 会为此行程单独获得一个空间,与其他内容互不相干。',
'docsync.connect.about.synologydrive': '最好使用只能访问此行程所用共享文件夹的 DSM 账户。',
// ── 选择存放位置 ──────────────────────────────────────────────────────────
'docsync.scope.title': '此行程应存放在 {provider} 的什么位置?',
'docsync.scope.intro': '只有这里的内容会被同步。文档服务中的其他内容都不会进入 TREK。',
'docsync.scope.createTitle': '新建一个',
'docsync.scope.createAction': '创建',
'docsync.scope.pickTitle': '或使用已有的',
'docsync.scope.search': '搜索',
'docsync.scope.noMatch': '没有匹配的内容。',
// ── 需要人来决定的事 ──────────────────────────────────────────────────────
'docsync.issues.title': '需要查看',
'docsync.issues.conflict': '两边都有改动。请选择保留哪一份。',
'docsync.issues.remote_missing': '已从文档服务中消失。TREK 中的副本仍在。',
'docsync.issues.rejected_type': '这里不允许此文件类型。',
'docsync.issues.too_large': '超出大小上限。',
'docsync.issues.error': '传输未能完成。',
'docsync.error.unknown_provider': '此实例不提供该文档服务。',
};