} label={t('common.upload')} onClick={() => setUploadFilesSignal(s => s + 1)} />
+
setOpenDocSyncSignal(s => s + 1)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
+
+
setOpenFilesTrashSignal(s => s + 1)} size={40} className="text-m-muted backdrop-blur-[24px] backdrop-saturate-[1.7]">
diff --git a/client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx
new file mode 100644
index 000000000..db48c875c
--- /dev/null
+++ b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.test.tsx
@@ -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] => ({
+ field_key: 'apiToken',
+ label: 'apiToken',
+ input_type: 'text',
+ placeholder: null,
+ hint: null,
+ required: true,
+ secret: true,
+ ...over,
+})
+
+const provider = (over: Partial): 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 => ({
+ id: 5,
+ providerId: 'paperless',
+ baseUrl: 'https://paperless.example',
+ settings: {},
+ secrets: {},
+ allowInsecureTls: false,
+ lastProbeState: 'ok',
+ lastProbeError: null,
+ ...over,
+})
+
+const link = (over: Partial = {}): 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 => ({
+ 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> = {}) {
+ return render(
+ ,
+ )
+}
+
+/** 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( )
+ rerender( )
+
+ 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()
+ })
+})
diff --git a/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx
new file mode 100644
index 000000000..a2f770f46
--- /dev/null
+++ b/client/src/mobile/screens/trip/tabs/MDocSyncSheet.tsx
@@ -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('list')
+ const [selected, setSelected] = useState(null)
+ const [pending, setPending] = useState(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 (
+ <>
+
+
+
+ {view !== 'list' && (
+
setView('list')} ariaLabel={t('common.back')}>
+
+
+ )}
+
+ }
+ title={title}
+ sub={view === 'list' ? tripTitle : undefined}
+ onClose={onClose}
+ closeLabel={t('common.close')}
+ />
+
+
+
+
+
+ {sync.loading ? (
+
+
+
+ ) : sync.providers.length === 0 ? (
+
+ ) : view === 'connect' && pending ? (
+
setView('scope')}
+ />
+ ) : view === 'scope' && pending && sync.connectionFor(pending.id) ? (
+ setView('list')}
+ />
+ ) : view === 'detail' && link ? (
+ p.id === link.providerId)?.name ?? link.providerId}
+ sync={sync}
+ isOwner={isOwner}
+ onUnlink={() => setConfirmUnlink(true)}
+ />
+ ) : (
+ { setSelected(id); setView('detail') }}
+ onAdd={openStore}
+ />
+ )}
+
+
+
+ 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 (
+
+ {links.length > 0 && (
+
+ {t('docsync.sidebar.connected')}
+
+ {links.map(l => (
+ p.id === l.providerId)?.name || l.providerId}
+ sub={l.remoteLabel || l.remoteRootPath || l.scopeKey}
+ state={l.lastSyncState}
+ onClick={() => onOpen(l.id)}
+ />
+ ))}
+
+
+ )}
+
+ {isOwner && available.length > 0 && (
+
+ {links.length === 0 ? t('docsync.addProvider') : t('docsync.addAnother')}
+
+ {available.map(p => (
+ onAdd(p)}
+ />
+ ))}
+
+
+ )}
+
+ {links.length === 0 && !isOwner && (
+
+ )}
+
+ )
+}
+
+/** One binding: the flow, a run button, its settings. */
+function DetailView({
+ link,
+ providerName,
+ sync,
+ isOwner,
+ onUnlink,
+}: {
+ link: DocSyncLink
+ providerName: string
+ sync: ReturnType
+ 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 (
+
+ {/* 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. */}
+
+
+ } count={holdings.inTrek} name={t('docsync.flow.trek')} />
+ : null}
+ count={holdings.atProvider}
+ name={providerName}
+ />
+
+
+
+ }
+ disabled={!isOwner}
+ onClick={() => toggle('push')}
+ />
+ }
+ disabled={!isOwner}
+ onClick={() => toggle('pull')}
+ />
+
+
+
+
|
+
|
+
+ {link.lastSyncState !== 'ok' && link.lastSyncState !== 'never' && (
+
+ {t(`docsync.linkState.${link.lastSyncState}`)}
+ {link.lastSyncError ? ` · ${t(`docsync.error.${link.lastSyncError}`)}` : ''}
+
+ )}
+
+
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"
+ >
+
+ {busy ? t('docsync.syncing') : t('docsync.syncNow')}
+
+
+ {isOwner && (
+ <>
+
+ void sync.updateLink(link.id, { syncEnabled: !link.syncEnabled })}
+ />
+
+
+
+
+ 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')}
+
+
+
+
+
+ {t('docsync.unlink')}
+
+ >
+ )}
+
+ )
+}
+
+/** Credentials for a store this instance offers but the trip has not used yet. */
+function ConnectView({
+ provider,
+ sync,
+ onDone,
+}: {
+ provider: DocSyncProvider
+ sync: ReturnType
+ onDone: () => void
+}) {
+ const { t } = useTranslation()
+ const [values, setValues] = useState>({})
+ 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 (
+
+
{t(`docsync.connect.about.${provider.id}`)}
+
+ {provider.fields.map(f => (
+
+
+ {t(`docsync.provider${cap(f.label)}`)}
+ {f.required && * }
+
+ { 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 && {f.hint} }
+
+ ))}
+
+ {verdict && (
+
+ {verdict.connected
+ ? verdict.account
+ ? t('docsync.connect.okAs', { account: verdict.account })
+ : t('docsync.connected')
+ : t(`docsync.error.${verdict.error ?? 'unknown'}`)}
+
+ )}
+
+
+ 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' ? : t('docsync.test')}
+
+ 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' ? : t('common.save')}
+
+
+
+ )
+}
+
+/** Which folder, tag or space this trip gets. */
+function ScopeView({
+ connectionId,
+ suggestedName,
+ sync,
+ onBound,
+}: {
+ connectionId: number
+ suggestedName: string
+ sync: ReturnType
+ onBound: () => void
+}) {
+ const { t } = useTranslation()
+ const [scopes, setScopes] = useState>['scopes'] | null>(null)
+ const [error, setError] = useState(null)
+ const [name, setName] = useState(suggestedName)
+ const [working, setWorking] = useState(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 (
+
+
{t('docsync.scope.intro')}
+
+
+ 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"
+ />
+ {
+ 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__' ? : }
+ {t('docsync.scope.createAction')}
+
+
+
+ {sync.error && (
+
+ {t(`docsync.error.${sync.error}`)}
+
+ )}
+
+
{t('docsync.scope.pickTitle')}
+
+ {scopes === null ? (
+
+ ) : error ? (
+
+ {t(`docsync.error.${error}`)}
+
+ ) : scopes.length === 0 ? (
+
+ ) : (
+
+ {scopes.map(s => (
+ 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"
+ >
+
+ {s.label}
+ {s.remoteRootPath && (
+ {s.remoteRootPath}
+ )}
+
+ {working === s.scopeKey
+ ?
+ : }
+
+ ))}
+
+ )}
+
+ )
+}
+
+/** 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 (
+
+
+ {Icon ? : null}
+
+
+ {title}
+ {sub}
+
+ {addable ? (
+
+ ) : (
+
+ {state && }
+
+
+ )}
+
+ )
+}
+
+/** 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 (
+
+
+ {glyph}
+
+ {count}
+
+ {t('docsync.flow.documents')}
+
+ {name}
+
+ )
+}
+
+/**
+ * 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 (
+
+ {/* 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. */}
+
+ {icon}
+
+ {label}
+ {active && }
+
+ )
+}
+
+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 (
+
+ )
+}
+
+function SectionLabel({ children }: { children: React.ReactNode }) {
+ return {children}
+}
+
+function Row({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ )
+}
+
+function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
+ return (
+
+
+ {label}
+ {hint && {hint} }
+
+ {children}
+
+ )
+}
+
+function Empty({ text, hint }: { text: string; hint?: string }) {
+ return (
+
+
{text}
+ {hint &&
{hint}
}
+
+ )
+}
+
+/** `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}`
+}
diff --git a/client/src/mobile/screens/trip/tabs/MFilesTab.tsx b/client/src/mobile/screens/trip/tabs/MFilesTab.tsx
index 154490f83..df33cf658 100644
--- a/client/src/mobile/screens/trip/tabs/MFilesTab.tsx
+++ b/client/src/mobile/screens/trip/tabs/MFilesTab.tsx
@@ -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('all')
const [menuFileId, setMenuFileId] = useState(null)
const [linkFileId, setLinkFileId] = useState(null)
+ const currentUser = useAuthStore(st => st.user)
const [trashOpen, setTrashOpen] = useState(false)
+ const [docSyncOpen, setDocSyncOpen] = useState(false)
const [lightboxIndex, setLightboxIndex] = useState(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) {
/>
setLinkFileId(null)} />
setTrashOpen(false)} />
+ setDocSyncOpen(false)}
+ />
{lightboxIndex != null && mediaFiles.length > 0 && (
Promise): void {
+ const reloadRef = useRef(reload)
+ reloadRef.current = reload
+
+ useEffect(() => {
+ if (!enabled) return
+ let timer: ReturnType | 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) => {
+ // '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])
+}
diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts
index 7a9d2ee74..a4b2c1756 100644
--- a/client/tests/helpers/mobileTrip.ts
+++ b/client/tests/helpers/mobileTrip.ts
@@ -325,6 +325,7 @@ export function buildShell(overrides: Partial = {}): MTripShellAp
exportCostsCsvSignal: 0,
uploadFilesSignal: 0,
openFilesTrashSignal: 0,
+ openDocSyncSignal: 0,
};
return { ...base, ...overrides };
}
diff --git a/server/src/nest/doc-sync/doc-sync-config.service.ts b/server/src/nest/doc-sync/doc-sync-config.service.ts
index d2b3096f3..0d50cf81c 100644
--- a/server/src/nest/doc-sync/doc-sync-config.service.ts
+++ b/server/src/nest/doc-sync/doc-sync-config.service.ts
@@ -74,8 +74,14 @@ export interface LinkRow {
interface ProviderFieldRow {
field_key: string;
+ /** i18n key suffix, never display text — the client resolves `docsync.`. */
+ 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[];
}
diff --git a/server/src/nest/doc-sync/doc-sync-webhook.controller.ts b/server/src/nest/doc-sync/doc-sync-webhook.controller.ts
index 263b1246b..8260a560d 100644
--- a/server/src/nest/doc-sync/doc-sync-webhook.controller.ts
+++ b/server/src/nest/doc-sync/doc-sync-webhook.controller.ts
@@ -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>();
+
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): 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
diff --git a/server/src/nest/doc-sync/doc-sync.constants.ts b/server/src/nest/doc-sync/doc-sync.constants.ts
index 50a0df041..a9b839451 100644
--- a/server/src/nest/doc-sync/doc-sync.constants.ts
+++ b/server/src/nest/doc-sync/doc-sync.constants.ts
@@ -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.
diff --git a/server/src/nest/doc-sync/doc-sync.controller.ts b/server/src/nest/doc-sync/doc-sync.controller.ts
index 69547bcc8..70b4cd7f8 100644
--- a/server/src/nest/doc-sync/doc-sync.controller.ts
+++ b/server/src/nest/doc-sync/doc-sync.controller.ts
@@ -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 });
}
diff --git a/server/src/nest/doc-sync/doc-sync.helpers.ts b/server/src/nest/doc-sync/doc-sync.helpers.ts
index 1e51c73a5..32b61bfd9 100644
--- a/server/src/nest/doc-sync/doc-sync.helpers.ts
+++ b/server/src/nest/doc-sync/doc-sync.helpers.ts
@@ -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();
+ 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();
for (const r of remote) if (!r.isDeleted) remoteById.set(r.remoteId, r);
const localById = new Map();
@@ -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 });
}
}
diff --git a/server/src/nest/doc-sync/doc-sync.service.ts b/server/src/nest/doc-sync/doc-sync.service.ts
index 55068cd01..2eb6308b0 100644
--- a/server/src/nest/doc-sync/doc-sync.service.ts
+++ b/server/src/nest/doc-sync/doc-sync.service.ts
@@ -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>;
@@ -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])),
};
}
diff --git a/server/src/nest/doc-sync/providers/synology-drive.provider.ts b/server/src/nest/doc-sync/providers/synology-drive.provider.ts
index 4314891ea..ee430ae5b 100644
--- a/server/src/nest/doc-sync/providers/synology-drive.provider.ts
+++ b/server/src/nest/doc-sync/providers/synology-drive.provider.ts
@@ -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;
diff --git a/server/tests/unit/nest/admin.service.test.ts b/server/tests/unit/nest/admin.service.test.ts
index f75cceae0..39c7dda7e 100644
--- a/server/tests/unit/nest/admin.service.test.ts
+++ b/server/tests/unit/nest/admin.service.test.ts
@@ -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', () => {
diff --git a/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts b/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts
index e8cfcddb4..a8293212a 100644
--- a/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts
+++ b/server/tests/unit/nest/doc-sync/doc-sync-webhook.controller.test.ts
@@ -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 => ({
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();
+ });
+});
diff --git a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts
index 4a1a868e9..51ae05f72 100644
--- a/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts
+++ b/server/tests/unit/nest/doc-sync/doc-sync.controller.test.ts
@@ -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();
+ });
+});
diff --git a/server/tests/unit/nest/doc-sync/doc-sync.helpers.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.helpers.test.ts
index 443e27a4e..d53a20d4d 100644
--- a/server/tests/unit/nest/doc-sync/doc-sync.helpers.test.ts
+++ b/server/tests/unit/nest/doc-sync/doc-sync.helpers.test.ts
@@ -52,6 +52,7 @@ const item = (over: Partial = {}): SyncItemState => ({
pushedSha256: null,
state: 'synced',
attempts: 0,
+ nextAttemptAt: null,
remoteMissingAt: null,
...over,
});
@@ -64,6 +65,7 @@ const plan = (over: Partial[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 = {}) =>
+ 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([]);
+ });
+});
diff --git a/shared/src/i18n/ar/docsync.ts b/shared/src/i18n/ar/docsync.ts
index 995e1b842..656fb3a62 100644
--- a/shared/src/i18n/ar/docsync.ts
+++ b/shared/src/i18n/ar/docsync.ts
@@ -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': 'هذا المزوّد غير متاح على هذا الخادم.',
};
diff --git a/shared/src/i18n/br/docsync.ts b/shared/src/i18n/br/docsync.ts
index a445c3a7f..715f06c8d 100644
--- a/shared/src/i18n/br/docsync.ts
+++ b/shared/src/i18n/br/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/ca/docsync.ts b/shared/src/i18n/ca/docsync.ts
index 0dfc07a74..dd86c3f03 100644
--- a/shared/src/i18n/ca/docsync.ts
+++ b/shared/src/i18n/ca/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/cs/docsync.ts b/shared/src/i18n/cs/docsync.ts
index f0ef9ab04..2272f53b1 100644
--- a/shared/src/i18n/cs/docsync.ts
+++ b/shared/src/i18n/cs/docsync.ts
@@ -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ý.',
};
diff --git a/shared/src/i18n/de/docsync.ts b/shared/src/i18n/de/docsync.ts
index c29b33ccc..5f060d15a 100644
--- a/shared/src/i18n/de/docsync.ts
+++ b/shared/src/i18n/de/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/en/docsync.ts b/shared/src/i18n/en/docsync.ts
index 1e1b17034..73c919f6b 100644
--- a/shared/src/i18n/en/docsync.ts
+++ b/shared/src/i18n/en/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/es/docsync.ts b/shared/src/i18n/es/docsync.ts
index 6814e2afa..d3711761c 100644
--- a/shared/src/i18n/es/docsync.ts
+++ b/shared/src/i18n/es/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/fr/docsync.ts b/shared/src/i18n/fr/docsync.ts
index 07fa64df5..5287cc23e 100644
--- a/shared/src/i18n/fr/docsync.ts
+++ b/shared/src/i18n/fr/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/gr/docsync.ts b/shared/src/i18n/gr/docsync.ts
index 03e9b1db6..c656b5da4 100644
--- a/shared/src/i18n/gr/docsync.ts
+++ b/shared/src/i18n/gr/docsync.ts
@@ -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': 'Αυτός ο πάροχος δεν είναι διαθέσιμος σε αυτή την εγκατάσταση.',
};
diff --git a/shared/src/i18n/hu/docsync.ts b/shared/src/i18n/hu/docsync.ts
index 3f56c9ce7..bfdd6cfd8 100644
--- a/shared/src/i18n/hu/docsync.ts
+++ b/shared/src/i18n/hu/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/id/docsync.ts b/shared/src/i18n/id/docsync.ts
index 4094098db..96d0d5452 100644
--- a/shared/src/i18n/id/docsync.ts
+++ b/shared/src/i18n/id/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/it/docsync.ts b/shared/src/i18n/it/docsync.ts
index 23a4311ca..98b0622f0 100644
--- a/shared/src/i18n/it/docsync.ts
+++ b/shared/src/i18n/it/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/ja/docsync.ts b/shared/src/i18n/ja/docsync.ts
index 4556b4942..a5b9023d3 100644
--- a/shared/src/i18n/ja/docsync.ts
+++ b/shared/src/i18n/ja/docsync.ts
@@ -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': 'この連携先はこのインスタンスでは利用できません。',
};
diff --git a/shared/src/i18n/ko/docsync.ts b/shared/src/i18n/ko/docsync.ts
index bd7ca3b93..4c2907eb0 100644
--- a/shared/src/i18n/ko/docsync.ts
+++ b/shared/src/i18n/ko/docsync.ts
@@ -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': '이 저장소는 이 인스턴스에서 사용할 수 없습니다.',
};
diff --git a/shared/src/i18n/nl/docsync.ts b/shared/src/i18n/nl/docsync.ts
index c20d8ef6f..6e8d55880 100644
--- a/shared/src/i18n/nl/docsync.ts
+++ b/shared/src/i18n/nl/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/pl/docsync.ts b/shared/src/i18n/pl/docsync.ts
index 8f017ea5a..73d4a3e4f 100644
--- a/shared/src/i18n/pl/docsync.ts
+++ b/shared/src/i18n/pl/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/ru/docsync.ts b/shared/src/i18n/ru/docsync.ts
index e2a6f9d94..f723474fe 100644
--- a/shared/src/i18n/ru/docsync.ts
+++ b/shared/src/i18n/ru/docsync.ts
@@ -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': 'Этот провайдер недоступен в этом экземпляре.',
};
diff --git a/shared/src/i18n/sv/docsync.ts b/shared/src/i18n/sv/docsync.ts
index 71315ee7e..c8f1f9186 100644
--- a/shared/src/i18n/sv/docsync.ts
+++ b/shared/src/i18n/sv/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/tr/docsync.ts b/shared/src/i18n/tr/docsync.ts
index 39c27e167..0a45623ed 100644
--- a/shared/src/i18n/tr/docsync.ts
+++ b/shared/src/i18n/tr/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/uk/docsync.ts b/shared/src/i18n/uk/docsync.ts
index cfd1aeb90..56f9fb357 100644
--- a/shared/src/i18n/uk/docsync.ts
+++ b/shared/src/i18n/uk/docsync.ts
@@ -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': 'Цей постачальник недоступний на цьому сервері.',
};
diff --git a/shared/src/i18n/vi/docsync.ts b/shared/src/i18n/vi/docsync.ts
index 7885e97ff..0094bd655 100644
--- a/shared/src/i18n/vi/docsync.ts
+++ b/shared/src/i18n/vi/docsync.ts
@@ -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.',
};
diff --git a/shared/src/i18n/zh-TW/docsync.ts b/shared/src/i18n/zh-TW/docsync.ts
index af7d51865..49a4d4897 100644
--- a/shared/src/i18n/zh-TW/docsync.ts
+++ b/shared/src/i18n/zh-TW/docsync.ts
@@ -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': '這個提供者在此執行個體上無法使用。',
};
diff --git a/shared/src/i18n/zh/docsync.ts b/shared/src/i18n/zh/docsync.ts
index 92d3bd862..edd691b0a 100644
--- a/shared/src/i18n/zh/docsync.ts
+++ b/shared/src/i18n/zh/docsync.ts
@@ -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': '此实例不提供该文档服务。',
};