mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 18:47:13 +02:00
Merge branch 'dev' into feature/billing
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Backup History must distinguish a restore from a backup (#2656).
|
||||
*
|
||||
* A restore writes a `github_backup_logs` row too — same table, same statuses,
|
||||
* `trigger: 'restore'`. The table rendered date / status / commit only, so the
|
||||
* row read as a successful backup dated now, while "Last backup" said something
|
||||
* else entirely. The column below is the only thing telling the two apart.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, within } from '@testing-library/react';
|
||||
import { render } from '../utils';
|
||||
import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
vi.mock('../../api/client', () => ({
|
||||
api: {
|
||||
getGitHubBackupConfig: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
repository_url: 'https://github.com/someone/backup',
|
||||
enabled: true,
|
||||
}),
|
||||
getGitHubBackupStatus: vi.fn().mockResolvedValue({ is_running: false, configured: true, enabled: true }),
|
||||
getGitHubBackupLogs: vi.fn(),
|
||||
getCloudStatus: vi.fn().mockResolvedValue({ is_authenticated: false }),
|
||||
getPrinters: vi.fn().mockResolvedValue([]),
|
||||
getPrinterStatus: vi.fn().mockResolvedValue({ connected: false }),
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getLocalBackups: vi.fn().mockResolvedValue([]),
|
||||
getLocalBackupStatus: vi.fn().mockResolvedValue({
|
||||
enabled: false,
|
||||
is_running: false,
|
||||
last_backup_at: null,
|
||||
last_status: null,
|
||||
last_message: null,
|
||||
next_run: null,
|
||||
}),
|
||||
checkLocalBackupPath: vi.fn().mockResolvedValue({ writable: true, path: '/data', code: 'ok' }),
|
||||
},
|
||||
}));
|
||||
|
||||
const log = (id: number, trigger: string) => ({
|
||||
id,
|
||||
config_id: 1,
|
||||
started_at: '2026-08-02T09:00:00',
|
||||
completed_at: '2026-08-02T09:00:05',
|
||||
status: 'success',
|
||||
trigger,
|
||||
commit_sha: null,
|
||||
files_changed: 0,
|
||||
error_message: null,
|
||||
});
|
||||
|
||||
const historyRows = async () => {
|
||||
const table = (await screen.findByText('History')).closest('div[id="card-backup-history"]');
|
||||
return within(table as HTMLElement).getAllByRole('row').slice(1); // drop the header
|
||||
};
|
||||
|
||||
describe('GitHubBackupSettings — backup history', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('labels a restore as a restore, not a successful backup', async () => {
|
||||
vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([log(1, 'restore')]);
|
||||
|
||||
render(<GitHubBackupSettings />);
|
||||
|
||||
const [row] = await historyRows();
|
||||
expect(within(row).getByText('Restore')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tells the three trigger kinds apart in one history', async () => {
|
||||
vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([
|
||||
log(1, 'restore'),
|
||||
log(2, 'scheduled'),
|
||||
log(3, 'manual'),
|
||||
]);
|
||||
|
||||
render(<GitHubBackupSettings />);
|
||||
|
||||
const rows = await historyRows();
|
||||
expect(within(rows[0]).getByText('Restore')).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText('Backup (scheduled)')).toBeInTheDocument();
|
||||
expect(within(rows[2]).getByText('Backup (manual)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the raw trigger rather than blanking an unknown one', async () => {
|
||||
vi.mocked(api.getGitHubBackupLogs).mockResolvedValue([log(1, 'something-new')]);
|
||||
|
||||
render(<GitHubBackupSettings />);
|
||||
|
||||
const [row] = await historyRows();
|
||||
expect(within(row).getByText('something-new')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -112,6 +112,31 @@ describe('GitHubBackupSettings - Provider Selection', () => {
|
||||
expect(options).toContain('forgejo');
|
||||
});
|
||||
|
||||
it('names the scopes of the selected provider under the token field', async () => {
|
||||
// #2775: one shared hint could only ever be right for one provider, and the
|
||||
// one it was right for was GitHub. A Forgejo user reading "Contents read and
|
||||
// write" has no such setting to find, and guesses wide.
|
||||
render(<GitHubBackupSettings />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox', { name: /git provider/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Contents read and write access/i)).toBeInTheDocument();
|
||||
|
||||
const select = screen.getByRole('combobox', { name: /git provider/i });
|
||||
fireEvent.change(select, { target: { value: 'forgejo' } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/write:repository/i)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(/Contents read and write access/i)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(select, { target: { value: 'gitlab' } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/read_repository/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('loads forgejo provider from existing config', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/config', () =>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* The Git Restore button must respect github:restore client-side (#2656).
|
||||
*
|
||||
* All three restore endpoints are gated on GITHUB_RESTORE server-side, so a
|
||||
* user without it gets a 403 the moment the modal opens its preview. Offering
|
||||
* the button anyway is an action that cannot work.
|
||||
*
|
||||
* Scoped to the button on purpose: the backup card itself stays visible,
|
||||
* because configuring backups is a separate permission.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
import { GitHubBackupSettings } from '../../components/GitHubBackupSettings';
|
||||
import { setAuthToken } from '../../api/client';
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
setAuthToken(null);
|
||||
});
|
||||
|
||||
/** A configured backup, which is what makes the action row render at all. */
|
||||
function mockConfiguredBackup() {
|
||||
server.use(
|
||||
http.get('*/api/v1/github-backup/config', () =>
|
||||
HttpResponse.json({
|
||||
id: 1,
|
||||
provider: 'github',
|
||||
repository_url: 'https://github.com/test/repo',
|
||||
branch: 'main',
|
||||
enabled: true,
|
||||
schedule_enabled: false,
|
||||
schedule_type: 'daily',
|
||||
schedule_time: '02:00',
|
||||
backup_kprofiles: true,
|
||||
backup_cloud_profiles: false,
|
||||
backup_spools: true,
|
||||
backup_archives: true,
|
||||
backup_settings: true,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
}),
|
||||
),
|
||||
http.get('*/api/v1/github-backup/status', () =>
|
||||
HttpResponse.json({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
is_running: false,
|
||||
restore_running: false,
|
||||
progress: null,
|
||||
last_backup_at: null,
|
||||
last_backup_status: null,
|
||||
next_run: null,
|
||||
}),
|
||||
),
|
||||
http.get('*/api/v1/github-backup/logs', () => HttpResponse.json([])),
|
||||
);
|
||||
}
|
||||
|
||||
function mockUserWith(permissions: string[]) {
|
||||
setAuthToken('test-token', 'session');
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/status', () =>
|
||||
HttpResponse.json({ auth_enabled: true, requires_setup: false }),
|
||||
),
|
||||
http.get('*/api/v1/auth/me', () =>
|
||||
HttpResponse.json({ id: 1, username: 'operator', is_admin: false, permissions }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('GitHubBackupSettings - github:restore gate', () => {
|
||||
it('hides the Restore from Git button without the permission', async () => {
|
||||
mockConfiguredBackup();
|
||||
mockUserWith(['settings:read', 'settings:update']);
|
||||
|
||||
render(<GitHubBackupSettings />);
|
||||
|
||||
// Wait for the action row itself, so an absent button is a real absence
|
||||
// rather than the card simply not having rendered yet.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Backup Now/i })).toBeInTheDocument());
|
||||
expect(screen.queryByRole('button', { name: /Restore from Git/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows it when the user has github:restore', async () => {
|
||||
mockConfiguredBackup();
|
||||
mockUserWith(['settings:read', 'github:restore']);
|
||||
|
||||
render(<GitHubBackupSettings />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /Restore from Git/i })).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows it when auth is disabled entirely', async () => {
|
||||
// hasPermission returns true with auth off, and it must stay that way -
|
||||
// a single-user instance has no permissions to grant.
|
||||
mockConfiguredBackup();
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/status', () =>
|
||||
HttpResponse.json({ auth_enabled: false, requires_setup: false }),
|
||||
),
|
||||
);
|
||||
|
||||
render(<GitHubBackupSettings />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /Restore from Git/i })).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* Tests for the Restore from Git Backup modal (#2656).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { render } from '../utils';
|
||||
import { server } from '../mocks/server';
|
||||
import { GitHubRestoreModal } from '../../components/GitHubRestoreModal';
|
||||
|
||||
const mockCommits = {
|
||||
success: true,
|
||||
message: 'OK',
|
||||
branch: 'main',
|
||||
commits: [
|
||||
{
|
||||
sha: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
|
||||
message: 'Bambuddy backup - 2026-07-02 10:00:00 UTC',
|
||||
author: 'Bambuddy',
|
||||
date: '2026-07-02T10:00:00Z',
|
||||
},
|
||||
{
|
||||
sha: 'bbb2222ccc3333ddd4444eee5555ffff0aaa1111',
|
||||
message: 'Bambuddy backup - 2026-07-01 10:00:00 UTC',
|
||||
author: 'Bambuddy',
|
||||
date: '2026-07-01T10:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockPreview = {
|
||||
success: true,
|
||||
message: 'OK',
|
||||
ref: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
|
||||
commit: mockCommits.commits[0],
|
||||
metadata_version: '1.0',
|
||||
// The server describes each caveat as a code plus typed params, carrying the
|
||||
// English rendering as `detail` for i18next's defaultValue (#2656). Note the
|
||||
// fixture's English deliberately differs from en.ts, so an assertion on the
|
||||
// locale string proves the code was translated rather than echoed.
|
||||
categories: [
|
||||
{
|
||||
category: 'archives',
|
||||
available: true,
|
||||
item_count: 30,
|
||||
detail: 'raw server English, should not be rendered',
|
||||
detail_code: 'archivesMetadataOnly',
|
||||
detail_params: {},
|
||||
},
|
||||
{ category: 'spools', available: true, item_count: 4, detail: null, detail_code: null, detail_params: {} },
|
||||
{ category: 'settings', available: true, item_count: 12, detail: null, detail_code: null, detail_params: {} },
|
||||
{
|
||||
category: 'kprofiles',
|
||||
available: false,
|
||||
item_count: 0,
|
||||
detail: 'raw server English, should not be rendered',
|
||||
detail_code: 'notPresent',
|
||||
detail_params: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// The default fixture has no K-profiles in the commit, which is the one category
|
||||
// whose row cannot be selected there.
|
||||
const mockPreviewWithKprofiles = {
|
||||
...mockPreview,
|
||||
categories: mockPreview.categories.map((c) =>
|
||||
c.category === 'kprofiles'
|
||||
? { category: 'kprofiles', available: true, item_count: 3, detail: null, detail_code: null, detail_params: {} }
|
||||
: c
|
||||
),
|
||||
};
|
||||
|
||||
type JsonBody = Record<string, unknown>;
|
||||
|
||||
function mockEndpoints(overrides: { preview?: JsonBody; commits?: JsonBody } = {}) {
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/commits', () =>
|
||||
HttpResponse.json(overrides.commits ?? (mockCommits as unknown as JsonBody))
|
||||
),
|
||||
http.get('/api/v1/github-backup/restore/preview', () =>
|
||||
HttpResponse.json(overrides.preview ?? (mockPreview as unknown as JsonBody))
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
describe('GitHubRestoreModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockEndpoints();
|
||||
});
|
||||
|
||||
it('renders the title and commit picker', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore from Git Backup')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to the latest commit and lists recent commits', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const select = (await screen.findByLabelText('Backup commit')) as HTMLSelectElement;
|
||||
expect(select.value).toBe('HEAD');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Latest backup/)).toBeInTheDocument();
|
||||
});
|
||||
// Commits are labelled by short SHA.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('option', { name: /aaa1111/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /bbb2222/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows item counts for categories present in the commit', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('30 in backup')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('4 in backup')).toBeInTheDocument();
|
||||
expect(screen.getByText('12 in backup')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('translates preview caveats rather than echoing the server English', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Metadata only - 3MF files and thumbnails are not in a Git backup')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryAllByText('raw server English, should not be rendered')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('falls back to the server English for a code it does not know', async () => {
|
||||
// A newer backend adding a detail_code this build has no key for must not
|
||||
// print the raw key at the user. Same defaultValue arm backup.pathCheck uses.
|
||||
mockEndpoints({
|
||||
preview: {
|
||||
...mockPreview,
|
||||
categories: [
|
||||
{
|
||||
category: 'spools',
|
||||
available: true,
|
||||
item_count: 4,
|
||||
detail: 'Something a future release explains',
|
||||
detail_code: 'somethingThisBuildHasNeverHeardOf',
|
||||
detail_params: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Something a future release explains')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables a category that is absent from the commit', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not present in this backup commit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
||||
// Four categories in fixed order: archives, spools, settings, kprofiles.
|
||||
expect(checkboxes).toHaveLength(4);
|
||||
expect(checkboxes[3].disabled).toBe(true);
|
||||
expect(checkboxes[0].disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps Restore disabled until a category is selected', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const restoreButton = await screen.findByRole('button', { name: /Restore$/ });
|
||||
expect(restoreButton).toBeDisabled();
|
||||
|
||||
// Wait for the preview to populate the category list before selecting.
|
||||
const checkboxes = await waitFor(() => {
|
||||
const found = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
||||
expect(found).toHaveLength(4);
|
||||
return found;
|
||||
});
|
||||
await userEvent.click(checkboxes[1]);
|
||||
|
||||
await waitFor(() => expect(restoreButton).not.toBeDisabled());
|
||||
expect(screen.getByText('1 selected')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('requires confirmation before sending the restore', async () => {
|
||||
let restoreCalls = 0;
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', async () => {
|
||||
restoreCalls += 1;
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: 'Restored 4 item(s) from aaa1111',
|
||||
log_id: 3,
|
||||
ref: mockPreview.ref,
|
||||
results: { spools: { restored: 4, skipped: 1, failed: 0, notes: [] } },
|
||||
});
|
||||
})
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
|
||||
// Confirm dialog appears; nothing sent yet.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restore from backup?')).toBeInTheDocument();
|
||||
});
|
||||
expect(restoreCalls).toBe(0);
|
||||
});
|
||||
|
||||
it('sends the selected categories and shows per-category results', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({
|
||||
success: true,
|
||||
message: 'Restored 4 item(s) from aaa1111',
|
||||
log_id: 3,
|
||||
ref: mockPreview.ref,
|
||||
results: {
|
||||
spools: {
|
||||
restored: 4,
|
||||
skipped: 1,
|
||||
failed: 0,
|
||||
notes: [
|
||||
{
|
||||
code: 'spoolUsageUnresolved',
|
||||
params: { count: 1 },
|
||||
message: 'raw server English, should not be rendered',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Restored 4 item(s) from aaa1111')).toBeInTheDocument();
|
||||
});
|
||||
// The commit posted is the sha the preview resolved to, not the symbolic
|
||||
// 'HEAD' the picker defaults to: re-resolving server-side would restore a
|
||||
// backup that landed after the preview the user actually approved.
|
||||
expect(body).toMatchObject({
|
||||
categories: ['spools'],
|
||||
overwrite_existing: false,
|
||||
ref: mockPreview.ref,
|
||||
});
|
||||
expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument();
|
||||
// The locale string with {{count}} filled in, not the server's English —
|
||||
// which is what makes the note translatable for a non-English user.
|
||||
expect(
|
||||
screen.getByText(/^1 usage record\(s\) skipped - their spool is not in this backup's spool list/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('raw server English, should not be rendered')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drops the selection while a newly-picked commit is still being inspected', async () => {
|
||||
// Switching commits keeps `selected` (it is only pruned once the new preview
|
||||
// lands), so the footer must not keep counting it: the categories belong to
|
||||
// the commit that was switched away from, and the user has not seen an item
|
||||
// count for the new one.
|
||||
let previewCalls = 0;
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/restore/preview', async () => {
|
||||
previewCalls += 1;
|
||||
// The second commit's preview never resolves, holding the modal in the
|
||||
// in-flight state the assertions below describe.
|
||||
if (previewCalls > 1) await delay('infinite');
|
||||
return HttpResponse.json(mockPreview as unknown as JsonBody);
|
||||
})
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument());
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText('Backup commit'), mockCommits.commits[1].sha);
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Reading backup contents...')).toBeInTheDocument());
|
||||
expect(screen.getByText('0 selected')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Restore$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('sends overwrite_existing when the toggle is on', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ success: true, message: 'done', log_id: 1, ref: 'x', results: {} });
|
||||
})
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('switch'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => expect(body).toMatchObject({ overwrite_existing: true }));
|
||||
});
|
||||
|
||||
it('warns more strongly when overwrite is enabled', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('switch'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/This cannot be undone/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Overwrite-off says existing entries stay as they are. K-profiles are the one
|
||||
// category that cannot honour that — writing a slot always replaces the
|
||||
// calibration on the printer — and the backend's note saying so only arrives
|
||||
// in the result panel, after the MQTT send. So the disclosure has to be on the
|
||||
// screen where the promise is made, before the user commits to it.
|
||||
describe('the K-profile exception to overwrite-off', () => {
|
||||
beforeEach(() => {
|
||||
mockEndpoints({ preview: mockPreviewWithKprofiles as unknown as JsonBody });
|
||||
});
|
||||
|
||||
it('appears beside the category as soon as it is selected', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[3]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/K-profiles are the exception/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// And it goes once overwrite is on, where nothing is promising otherwise.
|
||||
await userEvent.click(screen.getByRole('switch'));
|
||||
expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is part of the confirmation the user actually clicks through', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[3]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
expect(
|
||||
screen.getByText(/existing entries stay as they are\. K-profiles are the exception/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays out of the confirmation for the categories that do keep the promise', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
expect(screen.getByText(/existing entries stay as they are\.$/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is redundant with overwrite on, so it is not shown there', async () => {
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[3]);
|
||||
await userEvent.click(screen.getByRole('switch'));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
|
||||
await waitFor(() => screen.getByText(/This cannot be undone/));
|
||||
expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a preview failure instead of an empty category list', async () => {
|
||||
mockEndpoints({
|
||||
preview: {
|
||||
success: false,
|
||||
message: 'Commit or tree deadbee not found in the repository',
|
||||
ref: 'deadbee',
|
||||
categories: [],
|
||||
},
|
||||
});
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Commit or tree deadbee not found in the repository')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('surfaces a commit listing failure', async () => {
|
||||
mockEndpoints({
|
||||
commits: { success: false, message: 'Invalid access token', branch: 'main', commits: [] },
|
||||
});
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Invalid access token')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// A refused restore answers 200 with `success: false` and an empty `results`,
|
||||
// and two of the five refusals are ordinary conditions rather than errors — a
|
||||
// restore already running, and a backup mid-flight. Rendering the result panel
|
||||
// for those put a green tick and "reload so the restored data appears" above a
|
||||
// message saying nothing had been restored, i.e. a failure that read as a
|
||||
// success. Empty `results` is the load-bearing half: a failure that did write
|
||||
// carries its committed categories and does get the panel — see the partial
|
||||
// test below.
|
||||
it('reports a backend refusal such as the backup/restore mutex', async () => {
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', () =>
|
||||
HttpResponse.json({
|
||||
success: false,
|
||||
message: 'A backup is currently running. Wait for it to finish before restoring.',
|
||||
results: {},
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('A backup is currently running. Wait for it to finish before restoring.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Not the success panel: no reload hint, no "Reload now", and the form is
|
||||
// still there so the user can retry once the backup finishes.
|
||||
expect(screen.queryByText(/Reload Bambuddy so the restored data appears/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Reload now/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Restore$/ })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('does not refresh the data caches when a restore was refused', async () => {
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', () =>
|
||||
HttpResponse.json({ success: false, message: 'A restore is already running', results: {} })
|
||||
)
|
||||
);
|
||||
const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
await waitFor(() => screen.getByText('A restore is already running'));
|
||||
|
||||
const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
|
||||
// This refusal never reached a category, so `results` is empty and there is
|
||||
// nothing to re-read. A failure that committed one does invalidate — the
|
||||
// partial test below covers that side...
|
||||
expect(keys).not.toContain(JSON.stringify(['spools']));
|
||||
expect(keys).not.toContain(JSON.stringify(['archives']));
|
||||
// ...but a failure past the commit resolve writes a "failed" log row, so the
|
||||
// history is refreshed whatever the outcome.
|
||||
expect(keys).toContain(JSON.stringify(['github-backup-logs']));
|
||||
invalidate.mockRestore();
|
||||
});
|
||||
|
||||
// Categories commit as each one finishes, so a run that fails part-way leaves
|
||||
// the earlier ones on disk and reports them. The modal used to gate the whole
|
||||
// result panel — and the cache invalidation with it — on `success`, so those
|
||||
// rows were written, never shown, and never re-read: the app carried on
|
||||
// displaying pre-restore settings while the database held the restored ones.
|
||||
const partialRestore = {
|
||||
success: false,
|
||||
message: 'database is locked',
|
||||
log_id: 7,
|
||||
ref: 'a'.repeat(40),
|
||||
results: {
|
||||
archives: { restored: 12, skipped: 0, failed: 0, notes: [] },
|
||||
settings: { restored: 4, skipped: 1, failed: 0, notes: [] },
|
||||
},
|
||||
};
|
||||
|
||||
const runRestore = async () => {
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
};
|
||||
|
||||
it('reports the categories a part-way failure already committed', async () => {
|
||||
server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await runRestore();
|
||||
|
||||
// The tallies are the point: they name what is on disk.
|
||||
await waitFor(() => expect(screen.getByText('database is locked')).toBeInTheDocument());
|
||||
expect(screen.getByText(/12 restored/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4 restored/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/The categories listed above finished and are on disk/)).toBeInTheDocument();
|
||||
// And it must not read as a success — the run did not finish, so the
|
||||
// warning icon stands in for the green tick.
|
||||
expect(document.querySelector('svg.text-yellow-500')).toBeInTheDocument();
|
||||
expect(document.querySelector('svg.text-bambu-green')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes the data caches for a part-way failure, because rows landed', async () => {
|
||||
server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
|
||||
const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await runRestore();
|
||||
await waitFor(() => screen.getByText('database is locked'));
|
||||
|
||||
const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
|
||||
expect(keys).toContain(JSON.stringify(['archives']));
|
||||
expect(keys).toContain(JSON.stringify(['settings']));
|
||||
invalidate.mockRestore();
|
||||
});
|
||||
|
||||
// A provider-side failure answers 200 with `success: false`; a rejected
|
||||
// *request* throws in `request()`, leaving `data` undefined. Reading the
|
||||
// message off `data` alone meant the second kind rendered an empty modal —
|
||||
// picker holding only "Latest", every category greyed out, no explanation.
|
||||
it('explains a rejected preview request instead of greying out every category', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/restore/preview', () =>
|
||||
HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 })
|
||||
)
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not authenticated')).toBeInTheDocument();
|
||||
});
|
||||
// The category list is replaced by the error, not rendered disabled.
|
||||
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('explains a rejected commit-list request', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/github-backup/commits', () => HttpResponse.json({}, { status: 500 }))
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={vi.fn()} />);
|
||||
|
||||
// No detail in the body, so the generic string carries the message.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Could not read the backup repository|HTTP 500/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes via the close button', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(<GitHubRestoreModal onClose={onClose} />);
|
||||
|
||||
await waitFor(() => screen.getByText('Restore from Git Backup'));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A settings restore has to reach the rest of the app. It used to be the
|
||||
// opposite problem: SettingsPage's debounced auto-save wrote its pre-restore
|
||||
// form state back over the restore whenever ['settings'] refetched, so this
|
||||
// modal skipped that invalidation and pinned the cache instead. #2716 fixed
|
||||
// the page — it now reconciles a moved server snapshot field by field — and
|
||||
// the workaround came out with this commit.
|
||||
describe('a settings restore reaches the rest of the app', () => {
|
||||
/** Runs a restore returning `results`, leaving the modal on its summary. */
|
||||
async function restoreWith(results: Record<string, unknown>, onClose = vi.fn()) {
|
||||
server.use(
|
||||
http.post('/api/v1/github-backup/restore', () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
message: 'Restored 77 item(s) from aaa1111',
|
||||
log_id: 7,
|
||||
ref: mockPreview.ref,
|
||||
results,
|
||||
})
|
||||
)
|
||||
);
|
||||
render(<GitHubRestoreModal onClose={onClose} />);
|
||||
|
||||
const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
|
||||
await userEvent.click(checkboxes[1]);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
|
||||
await waitFor(() => screen.getByText('Restore from backup?'));
|
||||
const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
|
||||
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
|
||||
await waitFor(() => screen.getByText('Restored 77 item(s) from aaa1111'));
|
||||
return onClose;
|
||||
}
|
||||
|
||||
/** Replaces window.location with a reload spy for the duration of a test. */
|
||||
function stubReload() {
|
||||
const original = window.location;
|
||||
const reload = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...original, reload },
|
||||
});
|
||||
return {
|
||||
reload,
|
||||
restore: () =>
|
||||
Object.defineProperty(window, 'location', { configurable: true, value: original }),
|
||||
};
|
||||
}
|
||||
|
||||
it('invalidates the settings query alongside the other rewritten caches', async () => {
|
||||
const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
|
||||
await restoreWith({ settings: { restored: 77, skipped: 3, failed: 0, notes: [] } });
|
||||
|
||||
const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
|
||||
expect(keys).toContain(JSON.stringify(['spools']));
|
||||
expect(keys).toContain(JSON.stringify(['settings']));
|
||||
invalidate.mockRestore();
|
||||
});
|
||||
|
||||
it('reloads instead of merely closing after a settings restore', async () => {
|
||||
const loc = stubReload();
|
||||
try {
|
||||
const onClose = await restoreWith({
|
||||
settings: { restored: 77, skipped: 3, failed: 0, notes: [] },
|
||||
});
|
||||
|
||||
const closeButtons = screen.getAllByRole('button', { name: 'Close' });
|
||||
await userEvent.click(closeButtons[closeButtons.length - 1]);
|
||||
|
||||
expect(loc.reload).toHaveBeenCalled();
|
||||
// Invalidating ['settings'] only resyncs what reads that query. The
|
||||
// interface language and the auth state do not, so closing in place
|
||||
// would leave both showing their pre-restore values.
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
loc.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes normally when settings were not part of the restore', async () => {
|
||||
const loc = stubReload();
|
||||
try {
|
||||
const onClose = await restoreWith({
|
||||
spools: { restored: 4, skipped: 0, failed: 0, notes: [] },
|
||||
});
|
||||
|
||||
const closeButtons = screen.getAllByRole('button', { name: 'Close' });
|
||||
await userEvent.click(closeButtons[closeButtons.length - 1]);
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(loc.reload).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
loc.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -304,4 +304,127 @@ describe('ProjectDetailPage', () => {
|
||||
expect(screen.queryByText('Complete Sets')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('sub-project roll-up (#1264)', () => {
|
||||
const withChildren = {
|
||||
...mockProject,
|
||||
// Supplying `stats` opens the cost card, which reads `budget` — the API
|
||||
// always sends the key, so the mock has to as well.
|
||||
budget: null,
|
||||
descendant_count: 2,
|
||||
children: [
|
||||
{
|
||||
id: 2,
|
||||
name: 'Wing',
|
||||
color: '#ff0000',
|
||||
status: 'active',
|
||||
progress_percent: 50,
|
||||
descendant_count: 1,
|
||||
total_archives: 4,
|
||||
completed_prints: 4,
|
||||
total_print_time_hours: 6,
|
||||
total_filament_grams: 250,
|
||||
total_cost: 12.5,
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
total_archives: 1,
|
||||
total_items: 1,
|
||||
completed_prints: 1,
|
||||
failed_prints: 0,
|
||||
queued_prints: 0,
|
||||
in_progress_prints: 0,
|
||||
total_print_time_hours: 2,
|
||||
total_filament_grams: 100,
|
||||
progress_percent: null,
|
||||
parts_progress_percent: null,
|
||||
estimated_cost: 5,
|
||||
total_energy_kwh: 0,
|
||||
total_energy_cost: 0,
|
||||
remaining_prints: null,
|
||||
remaining_parts: null,
|
||||
bom_total_items: 0,
|
||||
bom_completed_items: 0,
|
||||
bom_cost: 0,
|
||||
},
|
||||
rollup_stats: {
|
||||
total_archives: 5,
|
||||
total_items: 5,
|
||||
completed_prints: 5,
|
||||
failed_prints: 0,
|
||||
queued_prints: 0,
|
||||
in_progress_prints: 0,
|
||||
total_print_time_hours: 8,
|
||||
total_filament_grams: 350,
|
||||
progress_percent: 40,
|
||||
parts_progress_percent: null,
|
||||
estimated_cost: 17.5,
|
||||
total_energy_kwh: 0,
|
||||
total_energy_cost: 0,
|
||||
remaining_prints: 3,
|
||||
remaining_parts: null,
|
||||
bom_total_items: 0,
|
||||
bom_completed_items: 0,
|
||||
bom_cost: 0,
|
||||
},
|
||||
};
|
||||
|
||||
it('shows the whole programme alongside the project own numbers', async () => {
|
||||
server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
|
||||
|
||||
render(<ProjectDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Including 2 sub-projects')).toBeInTheDocument();
|
||||
});
|
||||
// Both sets are on screen at once, which is the point — the roll-up must
|
||||
// not quietly replace what the project itself printed.
|
||||
expect(screen.getByText('350g')).toBeInTheDocument();
|
||||
expect(screen.getByText('100g')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('stays quiet when the project has no sub-projects', async () => {
|
||||
// Reversion-proof: if the roll-up were computed unconditionally it would
|
||||
// print a second, identical set of figures under every ordinary project.
|
||||
server.use(
|
||||
http.get('/api/v1/projects/:id', () =>
|
||||
HttpResponse.json({ ...withChildren, children: [], descendant_count: 0, rollup_stats: null })
|
||||
)
|
||||
);
|
||||
|
||||
render(<ProjectDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('100g')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(/Including .* sub-projects/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives each listed sub-project its own branch total', async () => {
|
||||
server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
|
||||
|
||||
render(<ProjectDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wing')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('4 jobs')).toBeInTheDocument();
|
||||
expect(screen.getByText('250g')).toBeInTheDocument();
|
||||
expect(screen.getByText('50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks a sub-project that is itself a parent', async () => {
|
||||
// Otherwise its figures look inflated for a single project rather than
|
||||
// covering the branch under it.
|
||||
server.use(http.get('/api/v1/projects/:id', () => HttpResponse.json(withChildren)));
|
||||
|
||||
render(<ProjectDetailPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wing')).toBeInTheDocument();
|
||||
});
|
||||
const row = screen.getByText('Wing').closest('a');
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.textContent).toContain('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { ProjectsPage, ProjectModal } from '../../pages/ProjectsPage';
|
||||
import { eligibleParents } from '../../utils/projectTree';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
@@ -466,4 +467,193 @@ describe('ProjectsPage', () => {
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ tags: null }));
|
||||
});
|
||||
});
|
||||
describe('nesting projects under a master project (#1264)', () => {
|
||||
const listItem = (over: Record<string, unknown>) => ({
|
||||
id: 1,
|
||||
name: 'Airframe',
|
||||
description: null,
|
||||
color: '#00ae42',
|
||||
status: 'active',
|
||||
target_count: null,
|
||||
target_parts_count: null,
|
||||
target_sets: null,
|
||||
budget: null,
|
||||
tags: null,
|
||||
due_date: null,
|
||||
priority: 'normal',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
archive_count: 0,
|
||||
total_items: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
queue_count: 0,
|
||||
progress_percent: null,
|
||||
parent_id: null,
|
||||
child_count: 0,
|
||||
archives: [],
|
||||
url: null,
|
||||
cover_image_filename: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('eligibleParents', () => {
|
||||
it('offers every other project when creating a new one', () => {
|
||||
const projects = [listItem({ id: 1 }), listItem({ id: 2, name: 'Wing' })];
|
||||
|
||||
expect(eligibleParents(projects, undefined).map((p) => p.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('never offers the project itself', () => {
|
||||
const projects = [listItem({ id: 1 }), listItem({ id: 2, name: 'Wing' })];
|
||||
|
||||
expect(eligibleParents(projects, 1).map((p) => p.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
it('never offers a project already nested underneath', () => {
|
||||
// The API rejects a cycle, so offering one would only produce an error
|
||||
// the user can do nothing about.
|
||||
const projects = [
|
||||
listItem({ id: 1 }),
|
||||
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
|
||||
listItem({ id: 3, name: 'Unrelated' }),
|
||||
];
|
||||
|
||||
expect(eligibleParents(projects, 1).map((p) => p.id)).toEqual([3]);
|
||||
});
|
||||
|
||||
it('excludes a grandchild listed before its own parent', () => {
|
||||
// Reversion-proof for a single-pass version: the list arrives in update
|
||||
// order, so a grandchild can precede the parent that blocks it.
|
||||
const projects = [
|
||||
listItem({ id: 3, name: 'Spar', parent_id: 2 }),
|
||||
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
|
||||
listItem({ id: 1 }),
|
||||
];
|
||||
|
||||
expect(eligibleParents(projects, 1)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('sends 0 to clear a parent, because null would read as omitted', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn();
|
||||
server.use(http.get('/api/v1/projects/', () => HttpResponse.json([listItem({ id: 9, name: 'Other' })])));
|
||||
|
||||
render(
|
||||
<ProjectModal
|
||||
project={listItem({ id: 1, parent_id: 9 })}
|
||||
onClose={() => {}}
|
||||
onSave={onSave as never}
|
||||
isLoading={false}
|
||||
currencySymbol="EUR"
|
||||
t={((k: string) => k) as never}
|
||||
/>,
|
||||
);
|
||||
|
||||
const parentSelect = () =>
|
||||
Array.from(document.querySelectorAll('select')).find((s) =>
|
||||
s.querySelector('option[value=""]'),
|
||||
) as HTMLSelectElement;
|
||||
await waitFor(() => expect(parentSelect().value).toBe('9'));
|
||||
|
||||
await user.selectOptions(parentSelect(), '');
|
||||
await user.click(screen.getByRole('button', { name: 'common.save' }));
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ parent_id: 0 }));
|
||||
});
|
||||
|
||||
it('draws a sub-project inside its parent group, not loose in the grid', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/projects/', () =>
|
||||
HttpResponse.json([
|
||||
listItem({ id: 1, name: 'Airframe', child_count: 1 }),
|
||||
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sub-projects of Airframe')).toBeInTheDocument();
|
||||
});
|
||||
// Two cards sitting columns apart cannot show that they belong together,
|
||||
// however they are captioned — the child has to be physically inside the
|
||||
// parent's group.
|
||||
const group = screen.getByText('Sub-projects of Airframe').closest('.col-span-full');
|
||||
expect(group).not.toBeNull();
|
||||
expect(group!.textContent).toContain('Wing');
|
||||
// And the caption is then redundant.
|
||||
expect(screen.queryByText('Part of Airframe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a sub-project visible when the filter hides its parent', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/projects/', ({ request }) => {
|
||||
const status = new URL(request.url).searchParams.get('status');
|
||||
const all = [
|
||||
listItem({ id: 1, name: 'Airframe', status: 'completed', child_count: 1 }),
|
||||
listItem({ id: 2, name: 'Wing', parent_id: 1 }),
|
||||
];
|
||||
return HttpResponse.json(status ? all.filter((p) => p.status === status) : all);
|
||||
}),
|
||||
);
|
||||
|
||||
// The page opens on the Active filter, which already hides the completed
|
||||
// parent and leaves its active child with nowhere to nest.
|
||||
render(<ProjectsPage />);
|
||||
|
||||
// Nothing to nest under, so it stays a top-level card — and then the
|
||||
// caption is the only thing that can say where it belongs.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Part of Airframe')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Sub-projects of Airframe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still shows every project when the data holds a parent cycle', async () => {
|
||||
// Grouping hides anything that has a visible parent, and a cycle has no
|
||||
// root — so A -> B -> A would take both projects off the page entirely.
|
||||
// A database written before the API refused a cycle can still hold one.
|
||||
server.use(
|
||||
http.get('/api/v1/projects/', () =>
|
||||
HttpResponse.json([
|
||||
listItem({ id: 1, name: 'Alpha', parent_id: 2 }),
|
||||
listItem({ id: 2, name: 'Beta', parent_id: 1 }),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Alpha')).toBeInTheDocument();
|
||||
});
|
||||
// Exactly once each: following the cycle round would redraw them.
|
||||
expect(screen.getAllByText('Alpha')).toHaveLength(1);
|
||||
expect(screen.getAllByText('Beta')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('nests a grandchild under its own parent, not under the top of the tree', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/projects/', () =>
|
||||
HttpResponse.json([
|
||||
listItem({ id: 1, name: 'Airframe', child_count: 1 }),
|
||||
listItem({ id: 2, name: 'Wing', parent_id: 1, child_count: 1 }),
|
||||
listItem({ id: 3, name: 'Spar', parent_id: 2 }),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Sub-projects of Wing')).toBeInTheDocument();
|
||||
});
|
||||
const wingGroup = screen.getByText('Sub-projects of Wing').closest('.col-span-full');
|
||||
expect(wingGroup!.textContent).toContain('Spar');
|
||||
// Flattening every descendant onto the root would lose the depth.
|
||||
expect(wingGroup!.textContent).not.toContain('Airframe');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* The drying popover has to name a material the printer will recognise.
|
||||
*
|
||||
* It seeds two things from one lookup: the temperature it prefills, and the
|
||||
* filament name the start command carries. The dropdown rendering that name
|
||||
* falls back to its first option when the value isn't in its list -- silently
|
||||
* -- so a lookup that can return something outside the table shows one material
|
||||
* and sends another. An AMS-HT holding Support for PLA/PETG displayed PLA and
|
||||
* told the printer PLA-S (#2774). These tests pin the invariant that closes
|
||||
* that: whatever comes back is a key the table has.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { resolveDryingPresetKey, type DryingPreset } from '../../utils/dryingPresets';
|
||||
|
||||
// The shipped table, as PrintersPage.tsx defines it.
|
||||
const PRESETS: Record<string, DryingPreset> = {
|
||||
'PLA': { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
|
||||
'PETG': { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
|
||||
'TPU': { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
|
||||
'ABS': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
|
||||
'ASA': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
|
||||
'PA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 12 },
|
||||
'PC': { n3f: 65, n3s: 80, n3f_hours: 12, n3s_hours: 8 },
|
||||
'PVA': { n3f: 65, n3s: 85, n3f_hours: 12, n3s_hours: 18 },
|
||||
};
|
||||
|
||||
describe('resolveDryingPresetKey', () => {
|
||||
it('always answers with a key the table has', () => {
|
||||
// The invariant behind #2774. Anything outside the table reaches the
|
||||
// dropdown as a value it will not display and the printer as a material
|
||||
// the user never chose.
|
||||
const trayTypes = [
|
||||
'PLA', 'PETG', 'ABS', 'ASA', 'TPU', 'PA', 'PC', 'PVA',
|
||||
'PLA-S', 'PLA-CF', 'PETG-CF', 'PET-CF', 'ABS-GF', 'ASA-CF', 'PAHT-CF',
|
||||
'PA6-CF', 'PPS-CF', 'PPA-CF', 'HIPS', 'PP', 'PE', 'EVA', 'PHA', 'PCTG',
|
||||
'Nylon', 'TPU for AMS', '', ' ', 'wildly unknown',
|
||||
];
|
||||
for (const trayType of trayTypes) {
|
||||
expect(PRESETS).toHaveProperty(resolveDryingPresetKey(trayType, PRESETS));
|
||||
}
|
||||
});
|
||||
|
||||
it('passes a listed material straight through', () => {
|
||||
expect(resolveDryingPresetKey('PETG', PRESETS)).toBe('PETG');
|
||||
expect(resolveDryingPresetKey('PVA', PRESETS)).toBe('PVA');
|
||||
});
|
||||
|
||||
it('is case-insensitive and ignores a trailing qualifier', () => {
|
||||
expect(resolveDryingPresetKey('petg', PRESETS)).toBe('PETG');
|
||||
expect(resolveDryingPresetKey('TPU for AMS', PRESETS)).toBe('TPU');
|
||||
});
|
||||
|
||||
it('dries a support material as its base', () => {
|
||||
// Support for PLA/PETG reports as PLA-S -- the case from the report.
|
||||
expect(resolveDryingPresetKey('PLA-S', PRESETS)).toBe('PLA');
|
||||
});
|
||||
|
||||
it('dries a composite as its base rather than defaulting to PLA', () => {
|
||||
// The reason the suffix is stripped instead of just falling back: PETG-CF
|
||||
// wants PETG's 65 degrees, and landing on PLA's 45 would quietly waste the
|
||||
// cycle.
|
||||
expect(resolveDryingPresetKey('PETG-CF', PRESETS)).toBe('PETG');
|
||||
expect(resolveDryingPresetKey('PLA-CF', PRESETS)).toBe('PLA');
|
||||
expect(resolveDryingPresetKey('ABS-GF', PRESETS)).toBe('ABS');
|
||||
expect(resolveDryingPresetKey('ASA-CF', PRESETS)).toBe('ASA');
|
||||
});
|
||||
|
||||
it('recognises the polyamide family under its own spellings', () => {
|
||||
expect(resolveDryingPresetKey('PA6-CF', PRESETS)).toBe('PA');
|
||||
expect(resolveDryingPresetKey('PAHT-CF', PRESETS)).toBe('PA');
|
||||
expect(resolveDryingPresetKey('Nylon', PRESETS)).toBe('PA');
|
||||
});
|
||||
|
||||
it('falls back to the coolest row for an unknown material', () => {
|
||||
// Under-drying an exotic filament wastes a cycle; PA's 85 degrees would
|
||||
// deform a PLA spool. So an unrecognised material must never inherit a
|
||||
// hotter row than PLA's.
|
||||
for (const unknown of ['PPS-CF', 'PEEK', 'wildly unknown']) {
|
||||
expect(resolveDryingPresetKey(unknown, PRESETS)).toBe('PLA');
|
||||
}
|
||||
});
|
||||
|
||||
it('handles an empty tray', () => {
|
||||
// No spool loaded -- the popover still has to open on something.
|
||||
expect(resolveDryingPresetKey(undefined, PRESETS)).toBe('PLA');
|
||||
expect(resolveDryingPresetKey(null, PRESETS)).toBe('PLA');
|
||||
expect(resolveDryingPresetKey('', PRESETS)).toBe('PLA');
|
||||
});
|
||||
|
||||
it('respects a custom preset table', () => {
|
||||
// Users can override the table from settings, so the lookup answers about
|
||||
// the table it was handed, not the shipped one.
|
||||
const custom = { ...PRESETS, 'PLA-S': { n3f: 55, n3s: 55, n3f_hours: 8, n3s_hours: 8 } };
|
||||
expect(resolveDryingPresetKey('PLA-S', custom)).toBe('PLA-S');
|
||||
});
|
||||
});
|
||||
@@ -908,12 +908,21 @@ export interface ProjectStats {
|
||||
bom_cost: number;
|
||||
}
|
||||
|
||||
// A sub-project as listed on its parent's page. The figures cover the child's
|
||||
// own subtree, so the listed rows add up to the parent's roll-up minus the
|
||||
// parent's own prints (#1264).
|
||||
export interface ProjectChildPreview {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string | null;
|
||||
status: string;
|
||||
progress_percent: number | null;
|
||||
descendant_count: number;
|
||||
total_archives: number;
|
||||
completed_prints: number;
|
||||
total_print_time_hours: number;
|
||||
total_filament_grams: number;
|
||||
total_cost: number; // Filament + energy + BOM, matching the parent's cost card
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
@@ -936,9 +945,13 @@ export interface Project {
|
||||
parent_id: number | null;
|
||||
parent_name: string | null;
|
||||
children: ProjectChildPreview[];
|
||||
descendant_count: number; // Sub-projects at any depth beneath this one (#1264)
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
stats?: ProjectStats;
|
||||
// This project's numbers combined with every sub-project's. Null when there
|
||||
// are none, since it would only repeat `stats` (#1264).
|
||||
rollup_stats?: ProjectStats | null;
|
||||
url: string | null; // External link rendered next to project name on the card (#1155)
|
||||
cover_image_filename: string | null; // Filename within project attachments dir (#1155)
|
||||
}
|
||||
@@ -985,6 +998,8 @@ export interface ProjectListItem {
|
||||
failed_count: number; // Sum of quantities for failed prints
|
||||
queue_count: number;
|
||||
progress_percent: number | null; // Plates progress
|
||||
parent_id: number | null; // #1264 — set when this is a sub-project
|
||||
child_count: number; // #1264 — direct sub-projects only
|
||||
archives: ArchivePreview[];
|
||||
url: string | null; // #1155
|
||||
cover_image_filename: string | null; // #1155
|
||||
@@ -2908,12 +2923,94 @@ export interface GitHubBackupStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
is_running: boolean;
|
||||
restore_running: boolean;
|
||||
progress: string | null;
|
||||
last_backup_at: string | null;
|
||||
last_backup_status: string | null;
|
||||
next_scheduled_run: string | null;
|
||||
}
|
||||
|
||||
// Restore from a Git backup (#2656). Cloud profiles are absent deliberately —
|
||||
// the backup collector never writes them, so there is nothing to restore.
|
||||
export type RestoreCategory = 'kprofiles' | 'settings' | 'spools' | 'archives';
|
||||
|
||||
export interface GitHubCommitInfo {
|
||||
sha: string;
|
||||
message: string;
|
||||
author: string;
|
||||
date: string;
|
||||
}
|
||||
|
||||
export interface GitHubCommitListResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
branch: string;
|
||||
commits: GitHubCommitInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Values the server interpolates into a translated note or preview detail.
|
||||
* Kept to strings and numbers on purpose — anything richer would have to be
|
||||
* formatted server-side and could not be translated.
|
||||
*/
|
||||
export type GitHubRestoreParams = Record<string, string | number>;
|
||||
|
||||
export interface GitHubRestorePreviewCategory {
|
||||
category: RestoreCategory;
|
||||
available: boolean;
|
||||
item_count: number;
|
||||
/** English rendering. Used as i18next's defaultValue, never shown on its own. */
|
||||
detail: string | null;
|
||||
/** Key under backup.restoreFromGit.details, or null when there is no caveat. */
|
||||
detail_code: string | null;
|
||||
detail_params: GitHubRestoreParams;
|
||||
}
|
||||
|
||||
export interface GitHubRestorePreview {
|
||||
success: boolean;
|
||||
message: string;
|
||||
ref: string;
|
||||
commit: GitHubCommitInfo | null;
|
||||
metadata_version: string | null;
|
||||
categories: GitHubRestorePreviewCategory[];
|
||||
}
|
||||
|
||||
export interface GitHubRestoreRequest {
|
||||
ref?: string;
|
||||
categories: RestoreCategory[];
|
||||
overwrite_existing?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One tally note, as a translation code plus its parameters (#2656).
|
||||
*
|
||||
* Same contract as {@link LocalBackupPathCheck} one card down: the server picks
|
||||
* the code and supplies typed params, and the client renders
|
||||
* ``t(`backup.restoreFromGit.notes.${code}`, { ...params, defaultValue: message })``.
|
||||
* A code the client does not know yet falls back to the English `message`
|
||||
* rather than showing the raw key.
|
||||
*/
|
||||
export interface GitHubRestoreNote {
|
||||
code: string;
|
||||
params: GitHubRestoreParams;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface GitHubRestoreCategoryResult {
|
||||
restored: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
notes: GitHubRestoreNote[];
|
||||
}
|
||||
|
||||
export interface GitHubRestoreResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
log_id: number | null;
|
||||
ref: string | null;
|
||||
results: Record<string, GitHubRestoreCategoryResult>;
|
||||
}
|
||||
|
||||
export interface LocalBackupStatus {
|
||||
enabled: boolean;
|
||||
schedule: string;
|
||||
@@ -6970,6 +7067,19 @@ export const api = {
|
||||
clearGitHubBackupLogs: (keepLast: number = 10) =>
|
||||
request<{ deleted: number; message: string }>(`/github-backup/logs?keep_last=${keepLast}`, { method: 'DELETE' }),
|
||||
|
||||
// Restore from a Git backup (#2656)
|
||||
getGitHubBackupCommits: (limit: number = 20) =>
|
||||
request<GitHubCommitListResponse>(`/github-backup/commits?limit=${limit}`),
|
||||
|
||||
getGitHubRestorePreview: (ref: string = 'HEAD') =>
|
||||
request<GitHubRestorePreview>(`/github-backup/restore/preview?ref=${encodeURIComponent(ref)}`),
|
||||
|
||||
restoreFromGitHub: (payload: GitHubRestoreRequest) =>
|
||||
request<GitHubRestoreResponse>('/github-backup/restore', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
// Scheduled local backups
|
||||
getLocalBackupStatus: () =>
|
||||
request<LocalBackupStatus>('/local-backup/status'),
|
||||
|
||||
@@ -36,10 +36,12 @@ import type {
|
||||
CloudAuthStatus,
|
||||
Printer,
|
||||
} from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { Card, CardContent, CardHeader } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { Toggle } from './Toggle';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { GitHubRestoreModal } from './GitHubRestoreModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { formatRelativeTime, parseUTCDate } from '../utils/date';
|
||||
|
||||
@@ -93,6 +95,16 @@ const PROVIDER_TOKEN_PLACEHOLDER: Record<GitProviderType, string> = {
|
||||
gitlab: 'glpat-xxxxxxxxxxxx',
|
||||
};
|
||||
|
||||
// Each provider names its scopes differently, so a single hint could only ever
|
||||
// be right for one of them (#2775). Naming the scopes up front is also what
|
||||
// keeps a token from being minted more permissive than a backup needs.
|
||||
const PROVIDER_TOKEN_HINT_I18N_KEY: Record<GitProviderType, string> = {
|
||||
github: 'backup.tokenHintGitHub',
|
||||
gitea: 'backup.tokenHintGitea',
|
||||
forgejo: 'backup.tokenHintForgejo',
|
||||
gitlab: 'backup.tokenHintGitLab',
|
||||
};
|
||||
|
||||
interface GitHubBackupAutosaveState {
|
||||
repository_url: string;
|
||||
branch: string;
|
||||
@@ -133,6 +145,14 @@ export function GitHubBackupSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { showToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
// All three restore endpoints are gated on GITHUB_RESTORE server-side, so a
|
||||
// user without it gets a 403 the moment the modal opens its preview. Hide the
|
||||
// button rather than offer an action that cannot work. Deliberately scoped to
|
||||
// the button: the card itself stays visible, since backup configuration is a
|
||||
// separate permission. hasPermission returns true when auth is off.
|
||||
const canRestoreFromGit = hasPermission('github:restore');
|
||||
|
||||
// Local state for form
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
@@ -158,6 +178,9 @@ export function GitHubBackupSettings() {
|
||||
const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Restore from the Git backup repository (#2656)
|
||||
const [showGitRestore, setShowGitRestore] = useState(false);
|
||||
|
||||
// Scheduled local backup state
|
||||
const [deleteConfirmFile, setDeleteConfirmFile] = useState<string | null>(null);
|
||||
const [restoreConfirmFile, setRestoreConfirmFile] = useState<string | null>(null);
|
||||
@@ -701,7 +724,7 @@ export function GitHubBackupSettings() {
|
||||
className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t('backup.tokenHint')}
|
||||
{t(PROVIDER_TOKEN_HINT_I18N_KEY[provider])}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -951,6 +974,18 @@ export function GitHubBackupSettings() {
|
||||
{testLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
{t('backup.test')}
|
||||
</Button>
|
||||
{/* Restore from the backup repo (#2656) */}
|
||||
{canRestoreFromGit && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowGitRestore(true)}
|
||||
disabled={status.restore_running}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t('backup.restoreFromGit.button')}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -1007,6 +1042,7 @@ export function GitHubBackupSettings() {
|
||||
<thead>
|
||||
<tr className="text-bambu-gray border-b border-bambu-dark-tertiary">
|
||||
<th className="text-left py-2 px-2">{t('backup.date')}</th>
|
||||
<th className="text-left py-2 px-2">{t('backup.trigger')}</th>
|
||||
<th className="text-left py-2 px-2">{t('backup.status')}</th>
|
||||
<th className="text-left py-2 px-2">{t('backup.commit')}</th>
|
||||
</tr>
|
||||
@@ -1015,6 +1051,12 @@ export function GitHubBackupSettings() {
|
||||
{logs.slice(0, 10).map((log) => (
|
||||
<tr key={log.id} className="border-b border-bambu-dark-tertiary/50 hover:bg-bambu-dark-secondary">
|
||||
<td className="py-2 px-2 text-white">{formatDateTime(log.started_at)}</td>
|
||||
{/* A restore writes a log row too, and without this it
|
||||
was indistinguishable from a backup: a successful
|
||||
run dated now, while Last backup said otherwise. */}
|
||||
<td className="py-2 px-2 text-bambu-gray">
|
||||
{t(`backup.triggers.${log.trigger}`, { defaultValue: log.trigger })}
|
||||
</td>
|
||||
<td className="py-2 px-2"><StatusBadge status={log.status} /></td>
|
||||
<td className="py-2 px-2">
|
||||
{log.commit_sha ? (
|
||||
@@ -1445,6 +1487,9 @@ export function GitHubBackupSettings() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Restore from the Git backup repository (#2656) */}
|
||||
{showGitRestore && <GitHubRestoreModal onClose={() => setShowGitRestore(false)} />}
|
||||
|
||||
{/* Delete Backup Confirmation Modal */}
|
||||
{deleteConfirmFile && (
|
||||
<ConfirmModal
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Loader2,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Settings as SettingsIcon,
|
||||
Thermometer,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from './Card';
|
||||
import { Button } from './Button';
|
||||
import { Toggle } from './Toggle';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import {
|
||||
api,
|
||||
type RestoreCategory,
|
||||
type GitHubRestoreParams,
|
||||
type GitHubRestoreResponse,
|
||||
} from '../api/client';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
interface GitHubRestoreModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a server-supplied translation code, falling back to its English text.
|
||||
*
|
||||
* The restore endpoints describe every note and preview caveat as a `code` plus
|
||||
* typed `params`, and carry the English rendering along as `message`. That is
|
||||
* the same contract `backup.pathCheck` already uses one card down in
|
||||
* GitHubBackupSettings — including the `defaultValue` arm, which is what keeps a
|
||||
* newer backend's unfamiliar code readable instead of printing the raw key.
|
||||
*/
|
||||
function translateCoded(
|
||||
t: TFunction,
|
||||
group: 'notes' | 'details',
|
||||
code: string | null | undefined,
|
||||
params: GitHubRestoreParams | undefined,
|
||||
fallback: string | null
|
||||
): string | null {
|
||||
if (!code) return fallback;
|
||||
return t(`backup.restoreFromGit.${group}.${code}`, {
|
||||
...(params ?? {}),
|
||||
defaultValue: fallback ?? code,
|
||||
});
|
||||
}
|
||||
|
||||
interface CategoryMeta {
|
||||
id: RestoreCategory;
|
||||
labelKey: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
// Order mirrors the order the backend applies them in. Labels reuse the keys
|
||||
// the backup checkbox group already ships in all locales.
|
||||
const CATEGORIES: CategoryMeta[] = [
|
||||
{ id: 'archives', labelKey: 'backup.printArchives', icon: <Archive className="w-4 h-4" /> },
|
||||
{ id: 'spools', labelKey: 'backup.spoolInventory', icon: <Palette className="w-4 h-4" /> },
|
||||
{ id: 'settings', labelKey: 'backup.appSettings', icon: <SettingsIcon className="w-4 h-4" /> },
|
||||
{ id: 'kprofiles', labelKey: 'backup.kProfiles', icon: <Thermometer className="w-4 h-4" /> },
|
||||
];
|
||||
|
||||
const CATEGORY_LABEL_KEYS: Record<string, string> = Object.fromEntries(
|
||||
CATEGORIES.map((c) => [c.id, c.labelKey])
|
||||
);
|
||||
|
||||
const LATEST = 'HEAD';
|
||||
|
||||
export function GitHubRestoreModal({ onClose }: GitHubRestoreModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selectedRef, setSelectedRef] = useState<string>(LATEST);
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
const [overwriteExisting, setOverwriteExisting] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [result, setResult] = useState<GitHubRestoreResponse | null>(null);
|
||||
|
||||
const commitsQuery = useQuery({
|
||||
queryKey: ['github-backup-commits'],
|
||||
queryFn: () => api.getGitHubBackupCommits(20),
|
||||
});
|
||||
|
||||
const previewQuery = useQuery({
|
||||
queryKey: ['github-restore-preview', selectedRef],
|
||||
queryFn: () => api.getGitHubRestorePreview(selectedRef),
|
||||
});
|
||||
|
||||
// Restore the exact commit the preview described, not the ref that was asked
|
||||
// for. They differ for the default "Latest backup" selection, which posts the
|
||||
// symbolic 'HEAD' and lets the backend re-resolve it — so a backup landing
|
||||
// between preview and restore would silently restore a different commit than
|
||||
// the one whose contents the user just approved.
|
||||
const resolvedRef = previewQuery.data?.success ? previewQuery.data.ref : selectedRef;
|
||||
|
||||
const availability = useMemo(() => {
|
||||
const map: Record<string, { available: boolean; itemCount: number; detail: string | null }> = {};
|
||||
previewQuery.data?.categories?.forEach((c) => {
|
||||
map[c.category] = {
|
||||
available: c.available,
|
||||
itemCount: c.item_count,
|
||||
detail: translateCoded(t, 'details', c.detail_code, c.detail_params, c.detail),
|
||||
};
|
||||
});
|
||||
return map;
|
||||
}, [previewQuery.data, t]);
|
||||
|
||||
// What a Restore click would actually send. `selected` on its own is not that:
|
||||
// it survives a commit switch by design (the pruning effect below only runs
|
||||
// once the new preview lands), so between picking a commit and its preview
|
||||
// resolving, `selected` still describes the *previous* commit while the
|
||||
// checkbox list is replaced by a spinner. Counting it raw put "2 selected"
|
||||
// and an enabled Restore button under that spinner, and clicking restored the
|
||||
// new commit with the old commit's categories — none of which the user had
|
||||
// seen an item count for. Gating on availability, exactly as the checkboxes
|
||||
// do, empties the list until the preview says otherwise, which also disables
|
||||
// the button.
|
||||
const selectedCategories = useMemo(
|
||||
() => CATEGORIES.filter((c) => selected[c.id] && availability[c.id]?.available).map((c) => c.id),
|
||||
[selected, availability]
|
||||
);
|
||||
const selectedCount = selectedCategories.length;
|
||||
|
||||
// Overwrite-off tells the user that existing entries stay as they are, and for
|
||||
// three of the four categories it keeps that promise. K-profiles cannot:
|
||||
// _restore_kprofiles takes no overwrite flag, because writing a slot is always
|
||||
// an overwrite on the printer — resolving the live cali_idx and publishing
|
||||
// extrusion_cali_set replaces whatever calibration that slot holds. The
|
||||
// backend does say so, but as a note in the result panel, i.e. after the MQTT
|
||||
// send has already happened and cannot be taken back. So the one screen that
|
||||
// explains overwrite-off has to carry the exception too, before the click.
|
||||
const warnKprofilesOverwrite = !overwriteExisting && selectedCategories.includes('kprofiles');
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.restoreFromGitHub({
|
||||
ref: resolvedRef,
|
||||
categories: selectedCategories,
|
||||
overwrite_existing: overwriteExisting,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setShowConfirm(false);
|
||||
// The endpoint answers 200 for a refused or failed restore too, with
|
||||
// `success: false` — and two of those are ordinary conditions, not
|
||||
// errors: another restore already running, and a backup being mid-flight.
|
||||
// Nothing was written for either, so they keep the form and show the red
|
||||
// block below; rendering the result panel for them put a green tick, no
|
||||
// tally at all and a "reload so the restored data appears" hint above a
|
||||
// message saying nothing had been restored.
|
||||
//
|
||||
// A failure that got as far as writing is the opposite case. Categories
|
||||
// commit as each one finishes, so a non-empty `results` names the ones
|
||||
// that are on disk — and the form over the top of them would be the same
|
||||
// "nothing was restored" misreading, this time with the data actually in.
|
||||
// So the panel is what wrote, not what succeeded.
|
||||
const wroteSomething = Object.keys(data.results ?? {}).length > 0;
|
||||
if (data.success || wroteSomething) {
|
||||
setResult(data);
|
||||
// A restore rewrites rows these caches hold. ['settings'] is one of
|
||||
// them: until #2716 was fixed on dev, invalidating it made
|
||||
// SettingsPage's debounced auto-save write the pre-restore form state
|
||||
// straight back over the restore, so this modal skipped it and pinned
|
||||
// the cache instead. That page now reconciles a moved server snapshot
|
||||
// field by field, so the restore no longer needs an exception.
|
||||
queryClient.invalidateQueries({ queryKey: ['spools'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['archives'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] });
|
||||
}
|
||||
// A failure that got as far as resolving the commit still writes a log row
|
||||
// (status "failed"), so refresh the history and status either way.
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['github-backup-status'] });
|
||||
},
|
||||
onError: () => setShowConfirm(false),
|
||||
});
|
||||
|
||||
const isRestoring = restoreMutation.isPending;
|
||||
|
||||
// A settings restore rewrites rows the whole app reads, and not all of them
|
||||
// through a query this modal can invalidate. The interface language is applied
|
||||
// by i18n.changeLanguage, called only from the SettingsPage dropdown and the
|
||||
// appliance-locale bootstrap; the auth state comes from AuthProvider's
|
||||
// mount-time getAuthStatus, not from ['settings'] at all. So every exit path
|
||||
// after a settings restore reloads rather than just closing.
|
||||
const settingsRestored = Boolean(result && 'settings' in result.results);
|
||||
const closeModal = useCallback(() => {
|
||||
if (settingsRestored) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}, [settingsRestored, onClose]);
|
||||
|
||||
// Close on Escape, except while a restore is in flight.
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isRestoring && !showConfirm) closeModal();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [closeModal, isRestoring, showConfirm]);
|
||||
|
||||
// Interrupting a restore mid-flight can leave a partly-applied category.
|
||||
useEffect(() => {
|
||||
if (!isRestoring) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [isRestoring]);
|
||||
|
||||
// Selecting a category that isn't in the newly-picked commit would send a
|
||||
// request the backend rejects, so drop those whenever the preview changes.
|
||||
useEffect(() => {
|
||||
if (!previewQuery.data) return;
|
||||
setSelected((prev) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
CATEGORIES.forEach((c) => {
|
||||
next[c.id] = Boolean(prev[c.id]) && Boolean(availability[c.id]?.available);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, [previewQuery.data, availability]);
|
||||
|
||||
const commits = commitsQuery.data?.commits ?? [];
|
||||
|
||||
const formatCommitLabel = (sha: string, message: string, date: string) => {
|
||||
const firstLine = (message || '').split('\n')[0];
|
||||
const when = date ? new Date(date).toLocaleString() : '';
|
||||
return `${sha.slice(0, 7)} — ${when}${firstLine ? ` — ${firstLine}` : ''}`;
|
||||
};
|
||||
|
||||
// Two ways these can fail, and both have to reach the user. A provider-side
|
||||
// failure (bad token, repo unreachable) answers 200 with `success: false` and
|
||||
// a message. A rejected *request* — a 401/403 once the session expires with
|
||||
// the modal open, a 500, the network dropping — throws in `request()`, so
|
||||
// `data` is undefined: reading the message off `data` alone left the picker
|
||||
// holding only "Latest" and every category greyed out by an empty availability
|
||||
// map, with nothing on screen saying why.
|
||||
const queryError = (query: { isError: boolean; error: unknown }) =>
|
||||
query.isError ? (query.error as Error)?.message || t('backup.restoreFromGit.loadFailed') : null;
|
||||
|
||||
const previewError =
|
||||
queryError(previewQuery) ??
|
||||
(previewQuery.data && !previewQuery.data.success ? previewQuery.data.message : null);
|
||||
const commitsError =
|
||||
queryError(commitsQuery) ??
|
||||
(commitsQuery.data && !commitsQuery.data.success ? commitsQuery.data.message : null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onClick={isRestoring ? undefined : closeModal}
|
||||
>
|
||||
<Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
||||
<CardContent className="p-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green">
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{t('backup.restoreFromGit.title')}</h3>
|
||||
<p className="text-sm text-bambu-gray">{t('backup.restoreFromGit.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={closeModal}
|
||||
disabled={isRestoring}
|
||||
aria-label={t('common.close')}
|
||||
className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
/* Result summary */
|
||||
<div className="p-4 space-y-3 max-h-[400px] overflow-y-auto">
|
||||
{/* A partial restore reaches this panel too — categories commit
|
||||
as they finish, so the tallies below are on disk even though
|
||||
the run did not get through them all. It must not read as a
|
||||
success: the message is the failure, and what follows is what
|
||||
survived it rather than what was asked for. */}
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
{result.success ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-bambu-green mt-0.5 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-white">{result.message}</span>
|
||||
</div>
|
||||
{!result.success && (
|
||||
<p className="text-xs text-bambu-gray">{t('backup.restoreFromGit.partialHint')}</p>
|
||||
)}
|
||||
{Object.entries(result.results).map(([name, tally]) => (
|
||||
<div key={name} className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{CATEGORY_LABEL_KEYS[name] ? t(CATEGORY_LABEL_KEYS[name]) : name}
|
||||
</span>
|
||||
<span className="text-xs text-bambu-gray">
|
||||
{t('backup.restoreFromGit.tally', {
|
||||
restored: tally.restored,
|
||||
skipped: tally.skipped,
|
||||
failed: tally.failed,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{tally.notes.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{tally.notes.map((note) => (
|
||||
// The server dedupes on (code, params), not on code
|
||||
// alone — two printers can both be offline — so the
|
||||
// key has to carry the params too.
|
||||
<li
|
||||
key={`${note.code}:${JSON.stringify(note.params)}`}
|
||||
className="text-xs text-bambu-gray flex items-start gap-1.5"
|
||||
>
|
||||
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
||||
<span>{translateCoded(t, 'notes', note.code, note.params, note.message)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="p-3 rounded-lg bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-300 dark:border-yellow-500/30">
|
||||
<p className="text-xs text-yellow-700 dark:text-yellow-200">
|
||||
{t('backup.restoreFromGit.reloadHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`p-4 space-y-4 max-h-[400px] overflow-y-auto ${isRestoring ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
{/* A restore that was refused or failed comes back here rather
|
||||
than to the result panel, so keep these above the fold. */}
|
||||
{restoreMutation.isError && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">
|
||||
{(restoreMutation.error as Error)?.message || t('backup.restoreFromGit.failed')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{restoreMutation.data && !restoreMutation.data.success && (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{restoreMutation.data.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Commit picker */}
|
||||
<div>
|
||||
<label htmlFor="restore-commit" className="block text-sm font-medium text-white mb-1">
|
||||
{t('backup.restoreFromGit.commitLabel')}
|
||||
</label>
|
||||
<select
|
||||
id="restore-commit"
|
||||
value={selectedRef}
|
||||
onChange={(e) => {
|
||||
setSelectedRef(e.target.value);
|
||||
// Drop the previous attempt's failure banner: it refers to
|
||||
// the commit that was just switched away from. (A *result*
|
||||
// cannot be showing here — the summary replaces this form.)
|
||||
restoreMutation.reset();
|
||||
}}
|
||||
disabled={isRestoring || commitsQuery.isLoading}
|
||||
className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:outline-none focus:border-bambu-green"
|
||||
>
|
||||
<option value={LATEST}>{t('backup.restoreFromGit.latestCommit')}</option>
|
||||
{commits.map((c) => (
|
||||
<option key={c.sha} value={c.sha}>
|
||||
{formatCommitLabel(c.sha, c.message, c.date)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{commitsError && <p className="mt-1 text-xs text-red-500 dark:text-red-400">{commitsError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Category selection */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white mb-2">{t('backup.restoreFromGit.categoriesLabel')}</p>
|
||||
{previewQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-bambu-gray p-3">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{t('backup.restoreFromGit.inspecting')}
|
||||
</div>
|
||||
) : previewError ? (
|
||||
<div className="p-3 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30">
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{previewError}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{CATEGORIES.map((category) => {
|
||||
const info = availability[category.id];
|
||||
const isAvailable = Boolean(info?.available);
|
||||
const isChecked = Boolean(selected[category.id]) && isAvailable;
|
||||
return (
|
||||
<label
|
||||
key={category.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${
|
||||
isAvailable ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'
|
||||
} ${
|
||||
isChecked
|
||||
? 'bg-bambu-green/10 border border-bambu-green/30'
|
||||
: 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
disabled={!isAvailable || isRestoring}
|
||||
onChange={() =>
|
||||
setSelected((prev) => ({ ...prev, [category.id]: !prev[category.id] }))
|
||||
}
|
||||
className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"
|
||||
/>
|
||||
<div className={isChecked ? 'text-bambu-green' : 'text-bambu-gray'}>{category.icon}</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-white text-sm font-medium">
|
||||
{t(category.labelKey)}
|
||||
{isAvailable && info?.itemCount ? (
|
||||
<span className="ml-2 text-xs text-bambu-gray">
|
||||
{t('backup.restoreFromGit.itemCount', { count: info.itemCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{info?.detail && <div className="text-xs text-bambu-gray">{info.detail}</div>}
|
||||
{category.id === 'kprofiles' && isChecked && warnKprofilesOverwrite && (
|
||||
<div className="text-xs text-yellow-700 dark:text-yellow-200">
|
||||
{t('backup.restoreFromGit.kprofilesOverwriteCaveat')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Overwrite toggle */}
|
||||
<div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{t('backup.restoreFromGit.overwriteLabel')}</p>
|
||||
<p className="text-xs text-bambu-gray">
|
||||
{overwriteExisting
|
||||
? t('backup.restoreFromGit.overwriteOn')
|
||||
: t('backup.restoreFromGit.overwriteOff')}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle checked={overwriteExisting} onChange={setOverwriteExisting} disabled={isRestoring} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary">
|
||||
{result ? (
|
||||
<>
|
||||
<span />
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={closeModal}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => window.location.reload()}
|
||||
className="bg-bambu-green hover:bg-bambu-green-dark"
|
||||
>
|
||||
{t('backup.reloadNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-bambu-gray">
|
||||
{t('backup.restoreFromGit.selectedCount', { count: selectedCount })}
|
||||
</span>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" onClick={closeModal} disabled={isRestoring}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={selectedCount === 0 || isRestoring}
|
||||
className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"
|
||||
>
|
||||
{isRestoring ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{t('backup.restoreFromGit.restoring')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
{t('backup.restore')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{showConfirm && (
|
||||
<ConfirmModal
|
||||
variant="danger"
|
||||
overlayZIndex="z-[110]"
|
||||
title={t('backup.restoreFromGit.confirmTitle')}
|
||||
message={
|
||||
overwriteExisting
|
||||
? t('backup.restoreFromGit.confirmMessageOverwrite')
|
||||
: warnKprofilesOverwrite
|
||||
? `${t('backup.restoreFromGit.confirmMessage')} ${t('backup.restoreFromGit.kprofilesOverwriteCaveat')}`
|
||||
: t('backup.restoreFromGit.confirmMessage')
|
||||
}
|
||||
confirmText={t('backup.restore')}
|
||||
isLoading={isRestoring}
|
||||
loadingText={t('backup.restoreFromGit.restoring')}
|
||||
onConfirm={() => restoreMutation.mutate()}
|
||||
onCancel={() => setShowConfirm(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3955,6 +3955,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: 'Übergeordnetes Projekt',
|
||||
parentNone: 'Keines (Hauptprojekt)',
|
||||
parentHint: 'Dieses Projekt einem anderen unterordnen, damit seine Zahlen in ein Hauptprojekt einfließen',
|
||||
partOf: 'Teil von {{name}}',
|
||||
subProjectCount: '{{count}} Unterprojekte',
|
||||
subProjectsOf: 'Unterprojekte von {{name}}',
|
||||
title: 'Projekte',
|
||||
subtitle: 'Organisieren und verfolgen Sie Ihre 3D-Druckprojekte',
|
||||
newProject: 'Neues Projekt',
|
||||
@@ -4102,6 +4108,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'Unterprojekte ({{count}})',
|
||||
jobs: '{{count}} Aufträge',
|
||||
},
|
||||
rollup: {
|
||||
title: 'Inklusive {{count}} Unterprojekten',
|
||||
progress: 'Gesamtfortschritt',
|
||||
percentComplete: '{{percent}}% abgeschlossen',
|
||||
},
|
||||
notes: {
|
||||
title: 'Notizen',
|
||||
@@ -4941,7 +4953,10 @@ export default {
|
||||
personalAccessToken: 'Persönlicher Zugriffstoken',
|
||||
tokenSaved: '(gespeichert)',
|
||||
enterNewToken: 'Neuen Token eingeben zum Aktualisieren',
|
||||
tokenHint: 'Feingranularer Token mit Lese-/Schreibberechtigung für Inhalte',
|
||||
tokenHintGitHub: 'Feingranularer Token mit Lese- und Schreibzugriff auf Contents oder klassischer Token mit dem Bereich repo.',
|
||||
tokenHintGitLab: 'Token mit dem Bereich api oder read_repository zusammen mit write_repository.',
|
||||
tokenHintGitea: 'Token mit dem Bereich write:repository.',
|
||||
tokenHintForgejo: 'Token mit dem Bereich write:repository. Ein Token, der nur für dieses eine Repository gilt, genügt.',
|
||||
branch: 'Branch',
|
||||
provider: 'Git-Anbieter',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -4987,11 +5002,80 @@ export default {
|
||||
clearedLogs: '{{count}} Protokolle gelöscht',
|
||||
failedToClearLogs: 'Protokolle löschen fehlgeschlagen: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Aus Git wiederherstellen',
|
||||
title: 'Aus Git-Backup wiederherstellen',
|
||||
subtitle: 'Commit auswählen und festlegen, was wiederhergestellt wird',
|
||||
commitLabel: 'Backup-Commit',
|
||||
latestCommit: 'Neuestes Backup (Branch-Spitze)',
|
||||
categoriesLabel: 'Was wiederherstellen',
|
||||
inspecting: 'Backup-Inhalt wird gelesen...',
|
||||
itemCount: '{{count}} im Backup',
|
||||
overwriteLabel: 'Vorhandene Einträge überschreiben',
|
||||
overwriteOn: 'Vorhandene Einträge werden aus dem Backup aktualisiert.',
|
||||
overwriteOff: 'Es werden nur fehlende Einträge ergänzt; vorhandene bleiben unverändert.',
|
||||
selectedCount: '{{count}} ausgewählt',
|
||||
restoring: 'Wird wiederhergestellt...',
|
||||
confirmTitle: 'Aus Backup wiederherstellen?',
|
||||
confirmMessage: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt. Fehlende Einträge werden ergänzt, vorhandene bleiben unverändert.',
|
||||
confirmMessageOverwrite: 'Die ausgewählten Kategorien werden aus diesem Commit wiederhergestellt und lokal vorhandene Einträge überschrieben. Dies kann nicht rückgängig gemacht werden.',
|
||||
kprofilesOverwriteCaveat: 'K-Profile sind die Ausnahme: Das Schreiben eines Slots ersetzt immer die Kalibrierung auf dem Drucker.',
|
||||
tally: '{{restored}} wiederhergestellt, {{skipped}} übersprungen, {{failed}} fehlgeschlagen',
|
||||
reloadHint: 'Bambuddy neu laden, damit die wiederhergestellten Daten überall erscheinen.',
|
||||
partialHint: 'Die oben aufgeführten Kategorien wurden abgeschlossen und sind gespeichert. Fehlende Kategorien wurden nicht ausgeführt.',
|
||||
failed: 'Wiederherstellung fehlgeschlagen.',
|
||||
loadFailed: 'Das Backup-Repository konnte nicht gelesen werden.',
|
||||
details: {
|
||||
notPresent: 'In diesem Backup-Commit nicht vorhanden',
|
||||
unreadableJson: 'Unlesbares JSON: {{paths}}',
|
||||
settingsNoPayload: 'Keine Einstellungen in den Daten',
|
||||
settingsCredentialsWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen',
|
||||
settingsCompanionWillSkip: '{{count}} zugangsdatenähnliche Schlüssel werden übersprungen und {{companion}} davon abhängige Schalter bleiben aus',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} Schalter bleiben aus - die dafür nötigen Zugangsdaten können nicht aus einem Backup wiederhergestellt werden',
|
||||
spoolsUsageCount: 'davon {{count}} Verbrauchseinträge',
|
||||
archivesMetadataOnly: 'Nur Metadaten - 3MF-Dateien und Vorschaubilder sind nicht im Git-Backup enthalten',
|
||||
kprofilesPrinterCount: 'über {{count}} Drucker',
|
||||
},
|
||||
notes: {
|
||||
noData: 'Keine Daten dieser Art in diesem Backup',
|
||||
archivesPrinterMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Drucker - Verknüpfung entfernt',
|
||||
archivesProjectMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Projekte - Verknüpfung entfernt',
|
||||
archivesOwnerCleared: 'Einige Archive verwiesen auf nicht mehr vorhandene Benutzer - Eigentümer entfernt. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
|
||||
archivesOwnerUnmatched: 'Einige Archive nennen einen Eigentümer, den es auf dieser Instanz nicht gibt - der Eigentümer wurde entfernt statt aus der Benutzer-ID der Sicherung geraten. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
|
||||
archivesOwnerUnknown: 'Einige Archive wurden ohne Eigentümer wiederhergestellt - diese Sicherung enthält keinen, daher sind sie nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
|
||||
archivesUndeleted: 'Seit dem Backup gelöschte Archive sind wieder sichtbar - Überschreiben war aktiv',
|
||||
archivesMetadataOnly: 'Wiederhergestellte Archive enthalten nur Metadaten - die 3MF- und Vorschaudateien sind nicht im Git-Backup enthalten',
|
||||
spoolUsageUnresolved: '{{count}} Verbrauchseinträge übersprungen - ihre Spule ist nicht in der Spulenliste dieses Backups, es gibt also nichts, woran sie hängen könnten.',
|
||||
spoolUsageUnlinked: '{{count}} Verbrauchseinträge ohne Verknüpfung zum Druckverlauf wiederhergestellt - wählen Sie Druckarchive zusammen mit dem Spulenbestand, um sie zu behalten.',
|
||||
spoolTagKept: '{{count}} Spulen-Tags unverändert gelassen - das Backup hätte einen inzwischen gescannten Tag gelöscht oder ihn auf eine zweite Spule verschoben.',
|
||||
settingsCredentialsSkipped: '{{count}} zugangsdatenähnliche Schlüssel übersprungen - Geheimnisse bitte manuell erneut eingeben',
|
||||
settingsAuthSkipped: '{{count}} Authentifizierungseinstellungen übersprungen - ändern Sie diese unter Einstellungen > Authentifizierung, damit die Aussperrprüfungen greifen',
|
||||
settingsCompanionSkipped: '{{keys}} bleiben ausgeschaltet - die jeweils benötigten Zugangsdaten lassen sich nicht aus einem Backup wiederherstellen und sind auf dieser Instanz nicht hinterlegt, ein Einschalten würde die Integration also ohne Authentifizierung lassen',
|
||||
settingsMqttRelayFailed: 'MQTT-Einstellungen wiederhergestellt, aber das Relay konnte nicht neu verbunden werden - Bambuddy neu starten',
|
||||
kprofilesAlwaysOverwrite: 'K-Profile überschreiben immer den passenden Slot auf dem Drucker',
|
||||
kprofilesAckUnreliable: 'Ein Drucker, der nicht antwortet, zählt weiterhin als wiederhergestellt - überprüfen Sie die Profile am Drucker',
|
||||
kprofilesPrinterMissing: 'Kein Drucker mit der Seriennummer {{serial}} - übersprungen',
|
||||
kprofilesPrinterOffline: '{{printer}} ({{serial}}) ist nicht verbunden - übersprungen',
|
||||
kprofilesUnknownNozzle: 'Unerwarteter Düsendurchmesser {{nozzle}} für {{serial}} - unverändert gesendet',
|
||||
kprofilesUnmatched: '{{count}} Profile für {{nozzle}} hatten kein Gegenstück auf {{printer}} - als neue Profile hinzugefügt',
|
||||
kprofilesSendFailed: '{{nozzle}}-Profile konnten nicht an {{printer}} ({{serial}}) gesendet werden',
|
||||
kprofilesRefused: '{{printer}} ({{serial}}) hat die {{nozzle}}-Profile abgelehnt: {{reason}}',
|
||||
kprofilesStepFailed: 'Der K-Profil-Schritt konnte nicht abgeschlossen werden - {{reason}}. Was zuvor wiederhergestellt wurde, ist trotzdem gespeichert.',
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'Verlauf',
|
||||
clear: 'Löschen',
|
||||
date: 'Datum',
|
||||
status: 'Status',
|
||||
trigger: 'Typ',
|
||||
triggers: {
|
||||
manual: 'Sicherung (manuell)',
|
||||
scheduled: 'Sicherung (geplant)',
|
||||
restore: 'Wiederherstellung',
|
||||
},
|
||||
commit: 'Commit',
|
||||
|
||||
// Local Backup
|
||||
|
||||
@@ -3984,6 +3984,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: 'Parent project',
|
||||
parentNone: 'None (top-level project)',
|
||||
parentHint: 'Nest this project under another one so its figures roll up into a master project',
|
||||
partOf: 'Part of {{name}}',
|
||||
subProjectCount: '{{count}} sub-projects',
|
||||
subProjectsOf: 'Sub-projects of {{name}}',
|
||||
title: 'Projects',
|
||||
subtitle: 'Organize and track your 3D printing projects',
|
||||
newProject: 'New Project',
|
||||
@@ -4131,6 +4137,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'Sub-projects ({{count}})',
|
||||
jobs: '{{count}} jobs',
|
||||
},
|
||||
rollup: {
|
||||
title: 'Including {{count}} sub-projects',
|
||||
progress: 'Overall progress',
|
||||
percentComplete: '{{percent}}% complete',
|
||||
},
|
||||
notes: {
|
||||
title: 'Notes',
|
||||
@@ -4984,7 +4996,10 @@ export default {
|
||||
personalAccessToken: 'Personal Access Token',
|
||||
tokenSaved: '(saved)',
|
||||
enterNewToken: 'Enter new token to update',
|
||||
tokenHint: 'Fine-grained token with Contents read/write permission',
|
||||
tokenHintGitHub: 'Fine-grained token with Contents read and write access, or a classic token with the repo scope.',
|
||||
tokenHintGitLab: 'Token with the api scope, or read_repository and write_repository together.',
|
||||
tokenHintGitea: 'Token with the write:repository scope.',
|
||||
tokenHintForgejo: 'Token with the write:repository scope. A token limited to this one repository is enough.',
|
||||
branch: 'Branch',
|
||||
provider: 'Git Provider',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -5030,11 +5045,85 @@ export default {
|
||||
clearedLogs: 'Cleared {{count}} logs',
|
||||
failedToClearLogs: 'Failed to clear logs: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Restore from Git',
|
||||
title: 'Restore from Git Backup',
|
||||
subtitle: 'Pick a commit and choose what to restore',
|
||||
commitLabel: 'Backup commit',
|
||||
latestCommit: 'Latest backup (branch tip)',
|
||||
categoriesLabel: 'What to restore',
|
||||
inspecting: 'Reading backup contents...',
|
||||
itemCount: '{{count}} in backup',
|
||||
overwriteLabel: 'Overwrite existing entries',
|
||||
overwriteOn: 'Existing entries will be updated from the backup.',
|
||||
overwriteOff: 'Only missing entries are added; existing ones are left untouched.',
|
||||
selectedCount: '{{count}} selected',
|
||||
restoring: 'Restoring...',
|
||||
confirmTitle: 'Restore from backup?',
|
||||
confirmMessage: 'The selected categories will be restored from this commit. Missing entries are added; existing entries stay as they are.',
|
||||
confirmMessageOverwrite: 'The selected categories will be restored from this commit, overwriting entries that already exist locally. This cannot be undone.',
|
||||
kprofilesOverwriteCaveat: 'K-profiles are the exception: writing a slot always replaces the calibration on the printer.',
|
||||
tally: '{{restored}} restored, {{skipped}} skipped, {{failed}} failed',
|
||||
reloadHint: 'Reload Bambuddy so the restored data appears everywhere.',
|
||||
partialHint: 'The categories listed above finished and are on disk. Any that are missing did not run.',
|
||||
failed: 'Restore failed.',
|
||||
loadFailed: 'Could not read the backup repository.',
|
||||
// Preview caveats. The server sends detail_code + detail_params and the
|
||||
// English detail as defaultValue, same contract as backup.pathCheck.
|
||||
details: {
|
||||
notPresent: 'Not present in this backup commit',
|
||||
unreadableJson: 'Unreadable JSON: {{paths}}',
|
||||
settingsNoPayload: 'No settings in payload',
|
||||
settingsCredentialsWillSkip: '{{count}} credential-like key(s) will be skipped',
|
||||
settingsCompanionWillSkip: '{{count}} credential-like key(s) will be skipped, and {{companion}} switch(es) that depend on them will be left off',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} switch(es) will be left off - the credential each one needs cannot be restored from a backup',
|
||||
spoolsUsageCount: 'including {{count}} usage record(s)',
|
||||
archivesMetadataOnly: 'Metadata only - 3MF files and thumbnails are not in a Git backup',
|
||||
kprofilesPrinterCount: 'across {{count}} printer(s)',
|
||||
},
|
||||
// Tally notes, same contract. noData is shared by all four categories:
|
||||
// the category heading renders beside it, so naming the category again
|
||||
// would be redundant.
|
||||
notes: {
|
||||
noData: 'No data of this kind in this backup',
|
||||
archivesPrinterMissing: 'Some archives referenced printers that no longer exist - link cleared',
|
||||
archivesProjectMissing: 'Some archives referenced projects that no longer exist - link cleared',
|
||||
archivesOwnerCleared: 'Some archives referenced users that no longer exist - owner cleared, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
|
||||
archivesOwnerUnmatched: 'Some archives name an owner this instance does not have - owner cleared rather than guessed from the backup\'s user id, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
|
||||
archivesOwnerUnknown: 'Some archives were restored without an owner - this backup does not record one, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
|
||||
archivesUndeleted: 'Archive(s) deleted since the backup are visible again - overwrite was on',
|
||||
archivesMetadataOnly: 'Restored archives carry metadata only - the 3MF and thumbnail files are not in a Git backup',
|
||||
spoolUsageUnresolved: '{{count}} usage record(s) skipped - their spool is not in this backup\'s spool list, so there is nothing to attach them to.',
|
||||
spoolUsageUnlinked: '{{count}} usage record(s) restored without their print-history link - select Print archives alongside Spool inventory to keep it.',
|
||||
spoolTagKept: '{{count}} spool tag(s) left as they are - the backup would have cleared a tag that has since been scanned, or moved one onto a second spool.',
|
||||
settingsCredentialsSkipped: '{{count}} credential-like key(s) skipped - re-enter secrets manually',
|
||||
settingsAuthSkipped: '{{count}} authentication setting(s) skipped - change those in Settings > Authentication so the lockout checks still run',
|
||||
settingsCompanionSkipped: '{{keys}} left switched off - the credential each one needs cannot be restored from a backup and this instance has none stored, so switching them on would leave the integration unauthenticated',
|
||||
settingsMqttRelayFailed: 'MQTT settings restored, but the relay could not be reconnected - restart Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'K-profiles always overwrite the matching slot on the printer',
|
||||
kprofilesAckUnreliable: 'A printer that does not answer still counts as restored - verify the profiles on the printer',
|
||||
kprofilesPrinterMissing: 'No printer with serial {{serial}} - skipped',
|
||||
kprofilesPrinterOffline: '{{printer}} ({{serial}}) is not connected - skipped',
|
||||
kprofilesUnknownNozzle: 'Unexpected nozzle diameter {{nozzle}} for {{serial}} - sent as-is',
|
||||
kprofilesUnmatched: '{{count}} profile(s) for {{nozzle}} had no counterpart on {{printer}} - added as new profiles',
|
||||
kprofilesSendFailed: 'Failed to send {{nozzle}} profiles to {{printer}} ({{serial}})',
|
||||
kprofilesRefused: '{{printer}} ({{serial}}) refused the {{nozzle}} profiles: {{reason}}',
|
||||
kprofilesStepFailed: 'The K-profile step could not be completed - {{reason}}. Anything restored before it is still saved.',
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'History',
|
||||
clear: 'Clear',
|
||||
date: 'Date',
|
||||
status: 'Status',
|
||||
trigger: 'Type',
|
||||
triggers: {
|
||||
manual: 'Backup (manual)',
|
||||
scheduled: 'Backup (scheduled)',
|
||||
restore: 'Restore',
|
||||
},
|
||||
commit: 'Commit',
|
||||
|
||||
// Local Backup
|
||||
|
||||
@@ -3957,6 +3957,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: 'Proyecto principal',
|
||||
parentNone: 'Ninguno (proyecto de nivel superior)',
|
||||
parentHint: 'Anida este proyecto en otro para que sus cifras se sumen en un proyecto maestro',
|
||||
partOf: 'Parte de {{name}}',
|
||||
subProjectCount: '{{count}} subproyectos',
|
||||
subProjectsOf: 'Subproyectos de {{name}}',
|
||||
title: 'Proyectos',
|
||||
subtitle: 'Organice y haga el seguimiento de sus proyectos de impresión 3D',
|
||||
newProject: 'Nuevo proyecto',
|
||||
@@ -4104,6 +4110,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'Subproyectos ({{count}})',
|
||||
jobs: '{{count}} trabajos',
|
||||
},
|
||||
rollup: {
|
||||
title: 'Incluyendo {{count}} subproyectos',
|
||||
progress: 'Progreso general',
|
||||
percentComplete: '{{percent}}% completado',
|
||||
},
|
||||
notes: {
|
||||
title: 'Notas',
|
||||
@@ -4948,7 +4960,10 @@ export default {
|
||||
personalAccessToken: 'Token de acceso personal',
|
||||
tokenSaved: '(guardado)',
|
||||
enterNewToken: 'Introduzca un nuevo token para actualizar',
|
||||
tokenHint: 'Token de granularidad fina con permiso de lectura/escritura de Contents',
|
||||
tokenHintGitHub: 'Token de granularidad fina con acceso de lectura y escritura a Contents, o token clásico con el ámbito repo.',
|
||||
tokenHintGitLab: 'Token con el ámbito api, o read_repository junto con write_repository.',
|
||||
tokenHintGitea: 'Token con el ámbito write:repository.',
|
||||
tokenHintForgejo: 'Token con el ámbito write:repository. Basta con un token limitado a este único repositorio.',
|
||||
branch: 'Rama',
|
||||
provider: 'Proveedor de Git',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -4994,11 +5009,80 @@ export default {
|
||||
clearedLogs: 'Se borraron {{count}} registros',
|
||||
failedToClearLogs: 'Error al borrar los registros: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Restaurar desde Git',
|
||||
title: 'Restaurar desde copia de Git',
|
||||
subtitle: 'Elige un commit y qué se debe restaurar',
|
||||
commitLabel: 'Commit de la copia',
|
||||
latestCommit: 'Última copia (punta de la rama)',
|
||||
categoriesLabel: 'Qué restaurar',
|
||||
inspecting: 'Leyendo el contenido de la copia...',
|
||||
itemCount: '{{count}} en la copia',
|
||||
overwriteLabel: 'Sobrescribir entradas existentes',
|
||||
overwriteOn: 'Las entradas existentes se actualizarán desde la copia.',
|
||||
overwriteOff: 'Solo se añaden las entradas que falten; las existentes no se modifican.',
|
||||
selectedCount: '{{count}} seleccionados',
|
||||
restoring: 'Restaurando...',
|
||||
confirmTitle: '¿Restaurar desde la copia?',
|
||||
confirmMessage: 'Las categorías seleccionadas se restaurarán desde este commit. Se añaden las entradas que falten y las existentes se mantienen igual.',
|
||||
confirmMessageOverwrite: 'Las categorías seleccionadas se restaurarán desde este commit y se sobrescribirán las entradas que ya existan localmente. Esto no se puede deshacer.',
|
||||
kprofilesOverwriteCaveat: 'Los perfiles K son la excepción: escribir una ranura siempre reemplaza la calibración en la impresora.',
|
||||
tally: '{{restored}} restaurados, {{skipped}} omitidos, {{failed}} fallidos',
|
||||
reloadHint: 'Recarga Bambuddy para que los datos restaurados aparezcan en todas partes.',
|
||||
partialHint: 'Las categorías indicadas arriba se completaron y están guardadas. Las que faltan no llegaron a ejecutarse.',
|
||||
failed: 'La restauración ha fallado.',
|
||||
loadFailed: 'No se pudo leer el repositorio de copias de seguridad.',
|
||||
details: {
|
||||
notPresent: 'No está presente en este commit de la copia',
|
||||
unreadableJson: 'JSON ilegible: {{paths}}',
|
||||
settingsNoPayload: 'No hay ajustes en los datos',
|
||||
settingsCredentialsWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial',
|
||||
settingsCompanionWillSkip: 'Se omitirán {{count}} claves con aspecto de credencial y {{companion}} interruptores que dependen de ellas quedarán desactivados',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} interruptores quedarán desactivados - la credencial que necesita cada uno no se puede restaurar desde una copia de seguridad',
|
||||
spoolsUsageCount: 'incluidos {{count}} registros de consumo',
|
||||
archivesMetadataOnly: 'Solo metadatos - los archivos 3MF y las miniaturas no están en una copia de Git',
|
||||
kprofilesPrinterCount: 'en {{count}} impresoras',
|
||||
},
|
||||
notes: {
|
||||
noData: 'No hay datos de este tipo en esta copia de seguridad',
|
||||
archivesPrinterMissing: 'Algunos archivos hacían referencia a impresoras que ya no existen - enlace eliminado',
|
||||
archivesProjectMissing: 'Algunos archivos hacían referencia a proyectos que ya no existen - enlace eliminado',
|
||||
archivesOwnerCleared: 'Algunos archivos hacían referencia a usuarios que ya no existen - se ha borrado el propietario, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
|
||||
archivesOwnerUnmatched: 'Algunos archivos indican un propietario que no existe en esta instancia - se ha borrado el propietario en lugar de deducirlo del id de usuario de la copia de seguridad, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
|
||||
archivesOwnerUnknown: 'Algunos archivos se restauraron sin propietario - esta copia de seguridad no registra ninguno, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
|
||||
archivesUndeleted: 'Los archivos eliminados desde la copia vuelven a estar visibles - la sobrescritura estaba activada',
|
||||
archivesMetadataOnly: 'Los archivos restaurados solo contienen metadatos - los ficheros 3MF y las miniaturas no están en una copia de Git',
|
||||
spoolUsageUnresolved: '{{count}} registros de consumo omitidos - su bobina no está en la lista de bobinas de esta copia, así que no hay nada a lo que asociarlos.',
|
||||
spoolUsageUnlinked: '{{count}} registros de consumo restaurados sin su enlace al historial de impresión - selecciona Archivos de impresión junto con Inventario de bobinas para conservarlo.',
|
||||
spoolTagKept: '{{count}} etiquetas de bobina se han dejado como estaban - la copia habría borrado una etiqueta escaneada desde entonces, o la habría movido a una segunda bobina.',
|
||||
settingsCredentialsSkipped: '{{count}} claves con aspecto de credencial omitidas - vuelve a introducir los secretos manualmente',
|
||||
settingsAuthSkipped: '{{count}} ajustes de autenticación omitidos - cámbialos en Ajustes > Autenticación para que sigan aplicándose las comprobaciones de bloqueo',
|
||||
settingsCompanionSkipped: '{{keys}} se han dejado desactivados - la credencial que cada uno necesita no puede restaurarse desde una copia y esta instancia no tiene ninguna guardada, así que activarlos dejaría la integración sin autenticación',
|
||||
settingsMqttRelayFailed: 'Ajustes MQTT restaurados, pero no se pudo reconectar el relé - reinicia Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'Los perfiles K siempre sobrescriben la ranura correspondiente en la impresora',
|
||||
kprofilesAckUnreliable: 'Una impresora que no responde sigue contando como restaurada - verifica los perfiles en la impresora',
|
||||
kprofilesPrinterMissing: 'No hay ninguna impresora con el número de serie {{serial}} - omitido',
|
||||
kprofilesPrinterOffline: '{{printer}} ({{serial}}) no está conectada - omitido',
|
||||
kprofilesUnknownNozzle: 'Diámetro de boquilla inesperado {{nozzle}} para {{serial}} - enviado tal cual',
|
||||
kprofilesUnmatched: '{{count}} perfiles para {{nozzle}} no tenían equivalente en {{printer}} - añadidos como perfiles nuevos',
|
||||
kprofilesSendFailed: 'No se pudieron enviar los perfiles de {{nozzle}} a {{printer}} ({{serial}})',
|
||||
kprofilesRefused: '{{printer}} ({{serial}}) rechazó los perfiles de {{nozzle}}: {{reason}}',
|
||||
kprofilesStepFailed: 'No se pudo completar el paso de los perfiles K - {{reason}}. Lo que se restauró antes sigue guardado.',
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'Historial',
|
||||
clear: 'Borrar',
|
||||
date: 'Fecha',
|
||||
status: 'Estado',
|
||||
trigger: 'Tipo',
|
||||
triggers: {
|
||||
manual: 'Copia (manual)',
|
||||
scheduled: 'Copia (programada)',
|
||||
restore: 'Restauración',
|
||||
},
|
||||
commit: 'Confirmación',
|
||||
|
||||
// Local Backup
|
||||
|
||||
@@ -3944,6 +3944,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: 'Projet parent',
|
||||
parentNone: 'Aucun (projet de premier niveau)',
|
||||
parentHint: 'Imbriquer ce projet dans un autre pour que ses chiffres remontent vers un projet principal',
|
||||
partOf: 'Fait partie de {{name}}',
|
||||
subProjectCount: '{{count}} sous-projets',
|
||||
subProjectsOf: 'Sous-projets de {{name}}',
|
||||
title: 'Projets',
|
||||
subtitle: 'Suivez vos projets d\'impression 3D',
|
||||
newProject: 'Nouveau Projet',
|
||||
@@ -4091,6 +4097,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'Sous-projets ({{count}})',
|
||||
jobs: '{{count}} travaux',
|
||||
},
|
||||
rollup: {
|
||||
title: 'Y compris {{count}} sous-projets',
|
||||
progress: 'Progression globale',
|
||||
percentComplete: '{{percent}}% terminé',
|
||||
},
|
||||
notes: {
|
||||
title: 'Notes',
|
||||
@@ -4930,7 +4942,10 @@ export default {
|
||||
personalAccessToken: 'Jeton d\'accès personnel',
|
||||
tokenSaved: '(enregistré)',
|
||||
enterNewToken: 'Entrez un nouveau jeton pour mettre à jour',
|
||||
tokenHint: 'Jeton à granularité fine avec permission de lecture/écriture du contenu',
|
||||
tokenHintGitHub: 'Jeton à granularité fine avec accès en lecture et écriture à Contents, ou jeton classique avec la portée repo.',
|
||||
tokenHintGitLab: 'Jeton avec la portée api, ou read_repository et write_repository ensemble.',
|
||||
tokenHintGitea: 'Jeton avec la portée write:repository.',
|
||||
tokenHintForgejo: 'Jeton avec la portée write:repository. Un jeton limité à ce seul dépôt suffit.',
|
||||
branch: 'Branche',
|
||||
provider: 'Fournisseur Git',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -4976,11 +4991,80 @@ export default {
|
||||
clearedLogs: '{{count}} journaux supprimés',
|
||||
failedToClearLogs: 'Échec de la suppression des journaux : {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Restaurer depuis Git',
|
||||
title: 'Restaurer depuis la sauvegarde Git',
|
||||
subtitle: 'Choisissez un commit et ce qui doit être restauré',
|
||||
commitLabel: 'Commit de sauvegarde',
|
||||
latestCommit: 'Dernière sauvegarde (tête de branche)',
|
||||
categoriesLabel: 'Éléments à restaurer',
|
||||
inspecting: 'Lecture du contenu de la sauvegarde...',
|
||||
itemCount: '{{count}} dans la sauvegarde',
|
||||
overwriteLabel: 'Écraser les entrées existantes',
|
||||
overwriteOn: 'Les entrées existantes seront mises à jour depuis la sauvegarde.',
|
||||
overwriteOff: 'Seules les entrées manquantes sont ajoutées ; les existantes ne sont pas modifiées.',
|
||||
selectedCount: '{{count}} sélectionné(s)',
|
||||
restoring: 'Restauration...',
|
||||
confirmTitle: 'Restaurer depuis la sauvegarde ?',
|
||||
confirmMessage: 'Les catégories sélectionnées seront restaurées depuis ce commit. Les entrées manquantes sont ajoutées, les existantes restent inchangées.',
|
||||
confirmMessageOverwrite: 'Les catégories sélectionnées seront restaurées depuis ce commit et les entrées déjà présentes localement seront écrasées. Cette action est irréversible.',
|
||||
kprofilesOverwriteCaveat: "Les profils K sont l'exception : écrire un emplacement remplace toujours la calibration sur l'imprimante.",
|
||||
tally: '{{restored}} restaurés, {{skipped}} ignorés, {{failed}} en échec',
|
||||
reloadHint: 'Rechargez Bambuddy pour que les données restaurées apparaissent partout.',
|
||||
partialHint: "Les catégories listées ci-dessus sont terminées et enregistrées. Celles qui manquent n'ont pas été exécutées.",
|
||||
failed: 'Échec de la restauration.',
|
||||
loadFailed: 'Impossible de lire le dépôt de sauvegarde.',
|
||||
details: {
|
||||
notPresent: 'Absent de ce commit de sauvegarde',
|
||||
unreadableJson: 'JSON illisible : {{paths}}',
|
||||
settingsNoPayload: 'Aucun réglage dans les données',
|
||||
settingsCredentialsWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées',
|
||||
settingsCompanionWillSkip: '{{count}} clés ressemblant à des identifiants seront ignorées, et {{companion}} interrupteurs qui en dépendent resteront désactivés',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} interrupteurs resteront désactivés - les identifiants dont chacun a besoin ne peuvent pas être restaurés depuis une sauvegarde',
|
||||
spoolsUsageCount: 'dont {{count}} enregistrements de consommation',
|
||||
archivesMetadataOnly: 'Métadonnées uniquement - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
|
||||
kprofilesPrinterCount: 'sur {{count}} imprimantes',
|
||||
},
|
||||
notes: {
|
||||
noData: 'Aucune donnée de ce type dans cette sauvegarde',
|
||||
archivesPrinterMissing: 'Certaines archives référençaient des imprimantes qui n\'existent plus - lien effacé',
|
||||
archivesProjectMissing: 'Certaines archives référençaient des projets qui n\'existent plus - lien effacé',
|
||||
archivesOwnerCleared: 'Certaines archives référençaient des utilisateurs qui n\'existent plus - propriétaire effacé, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
|
||||
archivesOwnerUnmatched: 'Certaines archives désignent un propriétaire absent de cette instance - le propriétaire a été effacé plutôt que déduit de l\'identifiant utilisateur de la sauvegarde, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
|
||||
archivesOwnerUnknown: 'Certaines archives ont été restaurées sans propriétaire - cette sauvegarde n\'en enregistre aucun, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
|
||||
archivesUndeleted: 'Les archives supprimées depuis la sauvegarde sont de nouveau visibles - l\'écrasement était activé',
|
||||
archivesMetadataOnly: 'Les archives restaurées ne contiennent que des métadonnées - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
|
||||
spoolUsageUnresolved: '{{count}} enregistrements de consommation ignorés - leur bobine ne figure pas dans la liste des bobines de cette sauvegarde, il n\'y a donc rien à quoi les rattacher.',
|
||||
spoolUsageUnlinked: '{{count}} enregistrements de consommation restaurés sans leur lien vers l\'historique d\'impression - sélectionnez Archives d\'impression en même temps que l\'Inventaire des bobines pour le conserver.',
|
||||
spoolTagKept: '{{count}} étiquettes de bobine laissées telles quelles - la sauvegarde aurait effacé une étiquette scannée depuis, ou l\'aurait déplacée sur une seconde bobine.',
|
||||
settingsCredentialsSkipped: '{{count}} clés ressemblant à des identifiants ignorées - ressaisissez les secrets manuellement',
|
||||
settingsAuthSkipped: '{{count}} réglages d\'authentification ignorés - modifiez-les dans Réglages > Authentification pour que les contrôles de verrouillage s\'appliquent',
|
||||
settingsCompanionSkipped: '{{keys}} laissés désactivés - l\'identifiant dont chacun a besoin ne peut pas être restauré depuis une sauvegarde et cette instance n\'en a aucun enregistré ; les activer laisserait donc l\'intégration sans authentification',
|
||||
settingsMqttRelayFailed: 'Réglages MQTT restaurés, mais le relais n\'a pas pu être reconnecté - redémarrez Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'Les profils K écrasent toujours l\'emplacement correspondant sur l\'imprimante',
|
||||
kprofilesAckUnreliable: 'Une imprimante qui ne répond pas compte quand même comme restaurée - vérifiez les profils sur l\'imprimante',
|
||||
kprofilesPrinterMissing: 'Aucune imprimante avec le numéro de série {{serial}} - ignoré',
|
||||
kprofilesPrinterOffline: '{{printer}} ({{serial}}) n\'est pas connectée - ignoré',
|
||||
kprofilesUnknownNozzle: 'Diamètre de buse inattendu {{nozzle}} pour {{serial}} - envoyé tel quel',
|
||||
kprofilesUnmatched: '{{count}} profils pour {{nozzle}} n\'avaient pas d\'équivalent sur {{printer}} - ajoutés comme nouveaux profils',
|
||||
kprofilesSendFailed: 'Impossible d\'envoyer les profils {{nozzle}} à {{printer}} ({{serial}})',
|
||||
kprofilesRefused: '{{printer}} ({{serial}}) a refusé les profils {{nozzle}} : {{reason}}',
|
||||
kprofilesStepFailed: 'L\'étape des profils K n\'a pas pu être terminée - {{reason}}. Ce qui a été restauré auparavant reste enregistré.',
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'Historique',
|
||||
clear: 'Effacer',
|
||||
date: 'Date',
|
||||
status: 'Statut',
|
||||
trigger: 'Type',
|
||||
triggers: {
|
||||
manual: 'Sauvegarde (manuelle)',
|
||||
scheduled: 'Sauvegarde (planifiée)',
|
||||
restore: 'Restauration',
|
||||
},
|
||||
commit: 'Commit',
|
||||
|
||||
// Local Backup
|
||||
|
||||
@@ -3943,6 +3943,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: 'Progetto principale',
|
||||
parentNone: 'Nessuno (progetto di primo livello)',
|
||||
parentHint: 'Annida questo progetto in un altro perché i suoi numeri confluiscano in un progetto principale',
|
||||
partOf: 'Parte di {{name}}',
|
||||
subProjectCount: '{{count}} sotto-progetti',
|
||||
subProjectsOf: 'Sotto-progetti di {{name}}',
|
||||
title: 'Progetti',
|
||||
subtitle: 'Organizza e traccia i tuoi progetti di stampa 3D',
|
||||
newProject: 'Nuovo progetto',
|
||||
@@ -4090,6 +4096,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'Sotto-progetti ({{count}})',
|
||||
jobs: '{{count}} lavori',
|
||||
},
|
||||
rollup: {
|
||||
title: 'Inclusi {{count}} sotto-progetti',
|
||||
progress: 'Avanzamento complessivo',
|
||||
percentComplete: '{{percent}}% completato',
|
||||
},
|
||||
notes: {
|
||||
title: 'Note',
|
||||
@@ -4929,7 +4941,10 @@ export default {
|
||||
personalAccessToken: 'Token di accesso personale',
|
||||
tokenSaved: '(salvato)',
|
||||
enterNewToken: 'Inserisci un nuovo token per aggiornare',
|
||||
tokenHint: 'Token a grana fine con permesso di lettura/scrittura dei contenuti',
|
||||
tokenHintGitHub: 'Token a grana fine con accesso in lettura e scrittura a Contents, oppure token classico con ambito repo.',
|
||||
tokenHintGitLab: 'Token con ambito api, oppure read_repository insieme a write_repository.',
|
||||
tokenHintGitea: 'Token con ambito write:repository.',
|
||||
tokenHintForgejo: 'Token con ambito write:repository. Basta un token limitato a questo solo repository.',
|
||||
branch: 'Branch',
|
||||
provider: 'Provider Git',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -4975,11 +4990,80 @@ export default {
|
||||
clearedLogs: '{{count}} log eliminati',
|
||||
failedToClearLogs: 'Eliminazione log fallita: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Ripristina da Git',
|
||||
title: 'Ripristina dal backup Git',
|
||||
subtitle: 'Scegli un commit e cosa ripristinare',
|
||||
commitLabel: 'Commit del backup',
|
||||
latestCommit: 'Ultimo backup (punta del branch)',
|
||||
categoriesLabel: 'Cosa ripristinare',
|
||||
inspecting: 'Lettura del contenuto del backup...',
|
||||
itemCount: '{{count}} nel backup',
|
||||
overwriteLabel: 'Sovrascrivi le voci esistenti',
|
||||
overwriteOn: 'Le voci esistenti verranno aggiornate dal backup.',
|
||||
overwriteOff: 'Vengono aggiunte solo le voci mancanti; quelle esistenti restano invariate.',
|
||||
selectedCount: '{{count}} selezionati',
|
||||
restoring: 'Ripristino in corso...',
|
||||
confirmTitle: 'Ripristinare dal backup?',
|
||||
confirmMessage: 'Le categorie selezionate verranno ripristinate da questo commit. Le voci mancanti vengono aggiunte, quelle esistenti restano invariate.',
|
||||
confirmMessageOverwrite: 'Le categorie selezionate verranno ripristinate da questo commit sovrascrivendo le voci già presenti in locale. Operazione non annullabile.',
|
||||
kprofilesOverwriteCaveat: "I profili K sono l'eccezione: scrivere uno slot sostituisce sempre la calibrazione sulla stampante.",
|
||||
tally: '{{restored}} ripristinati, {{skipped}} saltati, {{failed}} non riusciti',
|
||||
reloadHint: 'Ricarica Bambuddy per vedere i dati ripristinati in tutte le sezioni.',
|
||||
partialHint: 'Le categorie elencate sopra sono state completate e salvate. Quelle mancanti non sono state eseguite.',
|
||||
failed: 'Ripristino non riuscito.',
|
||||
loadFailed: 'Impossibile leggere il repository di backup.',
|
||||
details: {
|
||||
notPresent: 'Non presente in questo commit di backup',
|
||||
unreadableJson: 'JSON illeggibile: {{paths}}',
|
||||
settingsNoPayload: 'Nessuna impostazione nei dati',
|
||||
settingsCredentialsWillSkip: '{{count}} chiavi simili a credenziali verranno saltate',
|
||||
settingsCompanionWillSkip: '{{count}} chiavi simili a credenziali verranno saltate e {{companion}} interruttori che dipendono da esse resteranno disattivati',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} interruttori resteranno disattivati - le credenziali necessarie a ciascuno non possono essere ripristinate da un backup',
|
||||
spoolsUsageCount: 'inclusi {{count}} record di consumo',
|
||||
archivesMetadataOnly: 'Solo metadati - i file 3MF e le miniature non sono in un backup Git',
|
||||
kprofilesPrinterCount: 'su {{count}} stampanti',
|
||||
},
|
||||
notes: {
|
||||
noData: 'Nessun dato di questo tipo in questo backup',
|
||||
archivesPrinterMissing: 'Alcuni archivi facevano riferimento a stampanti non più esistenti - collegamento rimosso',
|
||||
archivesProjectMissing: 'Alcuni archivi facevano riferimento a progetti non più esistenti - collegamento rimosso',
|
||||
archivesOwnerCleared: 'Alcuni archivi facevano riferimento a utenti non più esistenti - proprietario rimosso, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
|
||||
archivesOwnerUnmatched: 'Alcuni archivi indicano un proprietario che questa istanza non ha - il proprietario è stato rimosso anziché dedotto dall\'id utente del backup, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
|
||||
archivesOwnerUnknown: 'Alcuni archivi sono stati ripristinati senza proprietario - questo backup non ne registra alcuno, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
|
||||
archivesUndeleted: 'Gli archivi eliminati dopo il backup sono di nuovo visibili - la sovrascrittura era attiva',
|
||||
archivesMetadataOnly: 'Gli archivi ripristinati contengono solo metadati - i file 3MF e le miniature non sono in un backup Git',
|
||||
spoolUsageUnresolved: '{{count}} record di consumo saltati - la loro bobina non è nell\'elenco bobine di questo backup, quindi non c\'è nulla a cui collegarli.',
|
||||
spoolUsageUnlinked: '{{count}} record di consumo ripristinati senza il collegamento alla cronologia di stampa - seleziona Archivi di stampa insieme a Inventario bobine per mantenerlo.',
|
||||
spoolTagKept: '{{count}} tag bobina lasciati invariati - il backup avrebbe cancellato un tag nel frattempo scansionato, oppure lo avrebbe spostato su una seconda bobina.',
|
||||
settingsCredentialsSkipped: '{{count}} chiavi simili a credenziali saltate - reinserisci i segreti manualmente',
|
||||
settingsAuthSkipped: '{{count}} impostazioni di autenticazione saltate - modificale in Impostazioni > Autenticazione così i controlli di blocco restano attivi',
|
||||
settingsCompanionSkipped: '{{keys}} lasciati disattivati - la credenziale richiesta da ciascuno non può essere ripristinata da un backup e questa istanza non ne ha nessuna salvata, quindi attivarli lascerebbe l\'integrazione senza autenticazione',
|
||||
settingsMqttRelayFailed: 'Impostazioni MQTT ripristinate, ma il relay non è stato riconnesso - riavvia Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'I profili K sovrascrivono sempre lo slot corrispondente sulla stampante',
|
||||
kprofilesAckUnreliable: 'Una stampante che non risponde conta comunque come ripristinata - verifica i profili sulla stampante',
|
||||
kprofilesPrinterMissing: 'Nessuna stampante con numero di serie {{serial}} - saltato',
|
||||
kprofilesPrinterOffline: '{{printer}} ({{serial}}) non è connessa - saltato',
|
||||
kprofilesUnknownNozzle: 'Diametro ugello inatteso {{nozzle}} per {{serial}} - inviato così com\'è',
|
||||
kprofilesUnmatched: '{{count}} profili per {{nozzle}} non avevano corrispondenza su {{printer}} - aggiunti come nuovi profili',
|
||||
kprofilesSendFailed: 'Impossibile inviare i profili {{nozzle}} a {{printer}} ({{serial}})',
|
||||
kprofilesRefused: '{{printer}} ({{serial}}) ha rifiutato i profili {{nozzle}}: {{reason}}',
|
||||
kprofilesStepFailed: 'Non è stato possibile completare il passaggio dei profili K - {{reason}}. Quanto ripristinato prima resta salvato.',
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'Cronologia',
|
||||
clear: 'Cancella',
|
||||
date: 'Data',
|
||||
status: 'Stato',
|
||||
trigger: 'Tipo',
|
||||
triggers: {
|
||||
manual: 'Backup (manuale)',
|
||||
scheduled: 'Backup (pianificato)',
|
||||
restore: 'Ripristino',
|
||||
},
|
||||
commit: 'Commit',
|
||||
|
||||
// Local Backup
|
||||
|
||||
@@ -3955,6 +3955,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: '親プロジェクト',
|
||||
parentNone: 'なし(トップレベルプロジェクト)',
|
||||
parentHint: 'このプロジェクトを別のプロジェクトの下に配置すると、数値がマスタープロジェクトに集計されます',
|
||||
partOf: '{{name}} の一部',
|
||||
subProjectCount: 'サブプロジェクト {{count}} 件',
|
||||
subProjectsOf: '{{name}} のサブプロジェクト',
|
||||
title: 'プロジェクト',
|
||||
subtitle: '印刷プロジェクトを管理',
|
||||
newProject: '新規プロジェクト',
|
||||
@@ -4102,6 +4108,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'サブプロジェクト ({{count}})',
|
||||
jobs: 'ジョブ {{count}} 件',
|
||||
},
|
||||
rollup: {
|
||||
title: 'サブプロジェクト {{count}} 件を含む',
|
||||
progress: '全体の進捗',
|
||||
percentComplete: '{{percent}}% 完了',
|
||||
},
|
||||
notes: {
|
||||
title: 'メモ',
|
||||
@@ -4941,7 +4953,10 @@ export default {
|
||||
personalAccessToken: '個人アクセストークン',
|
||||
tokenSaved: '(保存済み)',
|
||||
enterNewToken: '新しいトークンを入力して更新',
|
||||
tokenHint: 'Contents読み書き権限を持つきめ細かいトークン',
|
||||
tokenHintGitHub: 'Contentsの読み書き権限を持つきめ細かいトークン、またはrepoスコープを持つクラシックトークン。',
|
||||
tokenHintGitLab: 'apiスコープ、またはread_repositoryとwrite_repositoryの両方を持つトークン。',
|
||||
tokenHintGitea: 'write:repositoryスコープを持つトークン。',
|
||||
tokenHintForgejo: 'write:repositoryスコープを持つトークン。このリポジトリだけに限定したトークンで十分です。',
|
||||
branch: 'ブランチ',
|
||||
provider: 'Gitプロバイダー',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -4987,11 +5002,80 @@ export default {
|
||||
clearedLogs: '{{count}}件のログを削除しました',
|
||||
failedToClearLogs: 'ログの削除に失敗しました: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Git から復元',
|
||||
title: 'Git バックアップから復元',
|
||||
subtitle: 'コミットと復元する項目を選択します',
|
||||
commitLabel: 'バックアップのコミット',
|
||||
latestCommit: '最新のバックアップ (ブランチ先端)',
|
||||
categoriesLabel: '復元する項目',
|
||||
inspecting: 'バックアップの内容を読み込んでいます...',
|
||||
itemCount: 'バックアップ内に {{count}} 件',
|
||||
overwriteLabel: '既存のエントリを上書きする',
|
||||
overwriteOn: '既存のエントリはバックアップの内容で更新されます。',
|
||||
overwriteOff: '不足しているエントリのみ追加され、既存のものは変更されません。',
|
||||
selectedCount: '{{count}} 件選択中',
|
||||
restoring: '復元しています...',
|
||||
confirmTitle: 'バックアップから復元しますか?',
|
||||
confirmMessage: '選択したカテゴリをこのコミットから復元します。不足しているエントリが追加され、既存のエントリはそのまま残ります。',
|
||||
confirmMessageOverwrite: '選択したカテゴリをこのコミットから復元し、ローカルに既存のエントリを上書きします。この操作は取り消せません。',
|
||||
kprofilesOverwriteCaveat: 'Kプロファイルは例外です。スロットへの書き込みは、プリンター上のキャリブレーションを常に置き換えます。',
|
||||
tally: '復元 {{restored}} 件、スキップ {{skipped}} 件、失敗 {{failed}} 件',
|
||||
reloadHint: '復元したデータを全体に反映するには Bambuddy を再読み込みしてください。',
|
||||
partialHint: '上に表示されたカテゴリーは完了し、保存されています。表示されていないカテゴリーは実行されていません。',
|
||||
failed: '復元に失敗しました。',
|
||||
loadFailed: 'バックアップリポジトリを読み取れませんでした。',
|
||||
details: {
|
||||
notPresent: 'このバックアップコミットには含まれていません',
|
||||
unreadableJson: '読み取れない JSON: {{paths}}',
|
||||
settingsNoPayload: 'データに設定が含まれていません',
|
||||
settingsCredentialsWillSkip: '認証情報のようなキー {{count}} 件はスキップされます',
|
||||
settingsCompanionWillSkip: '認証情報のようなキー {{count}} 件はスキップされ、それらに依存するスイッチ {{companion}} 件はオフのままになります',
|
||||
settingsCompanionOnlyWillSkip: 'スイッチ {{companion}} 件はオフのままになります - それぞれに必要な認証情報はバックアップから復元できません',
|
||||
spoolsUsageCount: '使用履歴 {{count}} 件を含む',
|
||||
archivesMetadataOnly: 'メタデータのみ - 3MF ファイルとサムネイルは Git バックアップに含まれません',
|
||||
kprofilesPrinterCount: 'プリンター {{count}} 台分',
|
||||
},
|
||||
notes: {
|
||||
noData: 'この種類のデータはこのバックアップに含まれていません',
|
||||
archivesPrinterMissing: '一部のアーカイブが存在しないプリンターを参照していました - リンクを解除しました',
|
||||
archivesProjectMissing: '一部のアーカイブが存在しないプロジェクトを参照していました - リンクを解除しました',
|
||||
archivesOwnerCleared: '一部のアーカイブが存在しないユーザーを参照していました - 所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません',
|
||||
archivesOwnerUnmatched: '一部のアーカイブはこのインスタンスに存在しない所有者を指しています - バックアップのユーザー ID から推測せずに所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません',
|
||||
archivesOwnerUnknown: '一部のアーカイブは所有者なしで復元されました - このバックアップに所有者が記録されていないため、管理者が割り当てるまで archives:read_all 権限を持つユーザーにしか表示されません',
|
||||
archivesUndeleted: 'バックアップ後に削除されたアーカイブが再び表示されます - 上書きが有効でした',
|
||||
archivesMetadataOnly: '復元されたアーカイブはメタデータのみです - 3MF ファイルとサムネイルは Git バックアップに含まれません',
|
||||
spoolUsageUnresolved: '使用履歴 {{count}} 件をスキップしました - 対応するスプールがこのバックアップのスプール一覧にないため、紐付ける先がありません。',
|
||||
spoolUsageUnlinked: '使用履歴 {{count}} 件を印刷履歴へのリンクなしで復元しました - リンクを保持するにはスプール在庫と一緒に印刷アーカイブも選択してください。',
|
||||
spoolTagKept: 'スプールタグ {{count}} 件をそのままにしました - バックアップの内容ではその後スキャンされたタグが消えるか、別のスプールに移ってしまうためです。',
|
||||
settingsCredentialsSkipped: '認証情報のようなキー {{count}} 件をスキップしました - シークレットは手動で再入力してください',
|
||||
settingsAuthSkipped: '認証設定 {{count}} 件をスキップしました - ロックアウトチェックが働くよう、設定 > 認証で変更してください',
|
||||
settingsCompanionSkipped: '{{keys}} はオフのままにしました - 各項目に必要な認証情報はバックアップから復元できず、このインスタンスにも保存されていないため、オンにすると連携が未認証のままになります',
|
||||
settingsMqttRelayFailed: 'MQTT 設定を復元しましたが、リレーを再接続できませんでした - Bambuddy を再起動してください',
|
||||
kprofilesAlwaysOverwrite: 'K プロファイルは常にプリンター側の該当スロットを上書きします',
|
||||
kprofilesAckUnreliable: '応答しないプリンターも復元済みとして数えます - プロファイルはプリンター側で確認してください',
|
||||
kprofilesPrinterMissing: 'シリアル {{serial}} のプリンターがありません - スキップしました',
|
||||
kprofilesPrinterOffline: '{{printer}} ({{serial}}) は接続されていません - スキップしました',
|
||||
kprofilesUnknownNozzle: '{{serial}} に想定外のノズル径 {{nozzle}} - そのまま送信しました',
|
||||
kprofilesUnmatched: '{{nozzle}} 用のプロファイル {{count}} 件は {{printer}} に該当がありませんでした - 新規プロファイルとして追加しました',
|
||||
kprofilesSendFailed: '{{nozzle}} のプロファイルを {{printer}} ({{serial}}) に送信できませんでした',
|
||||
kprofilesRefused: '{{printer}} ({{serial}}) が {{nozzle}} のプロファイルを拒否しました: {{reason}}',
|
||||
kprofilesStepFailed: 'K プロファイルの処理を完了できませんでした - {{reason}}。それまでに復元された内容は保存されています。',
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
history: '履歴',
|
||||
clear: 'クリア',
|
||||
date: '日付',
|
||||
status: 'ステータス',
|
||||
trigger: '種類',
|
||||
triggers: {
|
||||
manual: 'バックアップ(手動)',
|
||||
scheduled: 'バックアップ(スケジュール)',
|
||||
restore: '復元',
|
||||
},
|
||||
commit: 'コミット',
|
||||
|
||||
// Local Backup
|
||||
|
||||
@@ -3762,6 +3762,12 @@ export default {
|
||||
}
|
||||
},
|
||||
projects: {
|
||||
parentLabel: '상위 프로젝트',
|
||||
parentNone: '없음 (최상위 프로젝트)',
|
||||
parentHint: '이 프로젝트를 다른 프로젝트 아래에 두면 수치가 마스터 프로젝트로 합산됩니다',
|
||||
partOf: '{{name}}의 일부',
|
||||
subProjectCount: '하위 프로젝트 {{count}}개',
|
||||
subProjectsOf: '{{name}}의 하위 프로젝트',
|
||||
title: '프로젝트',
|
||||
subtitle: '3D 인쇄 프로젝트 정리 및 추적',
|
||||
newProject: '새 프로젝트',
|
||||
@@ -3900,7 +3906,13 @@ export default {
|
||||
remaining: '남은 예산'
|
||||
},
|
||||
subProjects: {
|
||||
title: '하위 프로젝트 ({{count}})'
|
||||
title: '하위 프로젝트 ({{count}})',
|
||||
jobs: '작업 {{count}}개',
|
||||
},
|
||||
rollup: {
|
||||
title: '하위 프로젝트 {{count}}개 포함',
|
||||
progress: '전체 진행률',
|
||||
percentComplete: '{{percent}}% 완료',
|
||||
},
|
||||
notes: {
|
||||
title: '메모',
|
||||
@@ -4704,7 +4716,10 @@ export default {
|
||||
personalAccessToken: '개인 액세스 토큰',
|
||||
tokenSaved: '(저장됨)',
|
||||
enterNewToken: '업데이트하려면 새 토큰 입력',
|
||||
tokenHint: '콘텐츠 읽기/쓰기 권한이 있는 세분화된 토큰',
|
||||
tokenHintGitHub: 'Contents 읽기 및 쓰기 권한이 있는 세분화된 토큰 또는 repo 범위를 가진 클래식 토큰.',
|
||||
tokenHintGitLab: 'api 범위 또는 read_repository와 write_repository를 함께 가진 토큰.',
|
||||
tokenHintGitea: 'write:repository 범위를 가진 토큰.',
|
||||
tokenHintForgejo: 'write:repository 범위를 가진 토큰. 이 저장소 하나로만 제한된 토큰이면 충분합니다.',
|
||||
branch: '브랜치',
|
||||
provider: 'Git 제공자',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -4749,10 +4764,79 @@ export default {
|
||||
backupFailed2: '백업 실패: {{message}}',
|
||||
clearedLogs: '{{count}}개 로그 초기화됨',
|
||||
failedToClearLogs: '로그 초기화 실패: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Git에서 복원',
|
||||
title: 'Git 백업에서 복원',
|
||||
subtitle: '커밋과 복원할 항목을 선택하세요',
|
||||
commitLabel: '백업 커밋',
|
||||
latestCommit: '최신 백업 (브랜치 최신 커밋)',
|
||||
categoriesLabel: '복원할 항목',
|
||||
inspecting: '백업 내용을 읽고 있습니다...',
|
||||
itemCount: '백업에 {{count}}개',
|
||||
overwriteLabel: '기존 항목 덮어쓰기',
|
||||
overwriteOn: '기존 항목이 백업 내용으로 업데이트됩니다.',
|
||||
overwriteOff: '없는 항목만 추가되고 기존 항목은 그대로 유지됩니다.',
|
||||
selectedCount: '{{count}}개 선택됨',
|
||||
restoring: '복원 중...',
|
||||
confirmTitle: '백업에서 복원하시겠습니까?',
|
||||
confirmMessage: '선택한 항목을 이 커밋에서 복원합니다. 없는 항목은 추가되고 기존 항목은 그대로 유지됩니다.',
|
||||
confirmMessageOverwrite: '선택한 항목을 이 커밋에서 복원하고 로컬에 이미 있는 항목을 덮어씁니다. 이 작업은 취소할 수 없습니다.',
|
||||
kprofilesOverwriteCaveat: 'K 프로파일은 예외입니다. 슬롯에 쓰면 프린터의 캘리브레이션이 항상 교체됩니다.',
|
||||
tally: '복원 {{restored}}개, 건너뜀 {{skipped}}개, 실패 {{failed}}개',
|
||||
reloadHint: '복원된 데이터가 모든 화면에 반영되도록 Bambuddy를 새로 고치세요.',
|
||||
partialHint: '위에 표시된 카테고리는 완료되어 저장되었습니다. 표시되지 않은 카테고리는 실행되지 않았습니다.',
|
||||
failed: '복원에 실패했습니다.',
|
||||
loadFailed: '백업 저장소를 읽을 수 없습니다.',
|
||||
details: {
|
||||
notPresent: '이 백업 커밋에는 없습니다',
|
||||
unreadableJson: '읽을 수 없는 JSON: {{paths}}',
|
||||
settingsNoPayload: '데이터에 설정이 없습니다',
|
||||
settingsCredentialsWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뜁니다',
|
||||
settingsCompanionWillSkip: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰고, 이에 의존하는 스위치 {{companion}}개는 꺼진 상태로 둡니다',
|
||||
settingsCompanionOnlyWillSkip: '스위치 {{companion}}개는 꺼진 상태로 둡니다 - 각각에 필요한 자격 증명은 백업에서 복원할 수 없습니다',
|
||||
spoolsUsageCount: '사용 기록 {{count}}건 포함',
|
||||
archivesMetadataOnly: '메타데이터만 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
|
||||
kprofilesPrinterCount: '프린터 {{count}}대 분량',
|
||||
},
|
||||
notes: {
|
||||
noData: '이 백업에는 이런 종류의 데이터가 없습니다',
|
||||
archivesPrinterMissing: '일부 아카이브가 더 이상 존재하지 않는 프린터를 참조했습니다 - 연결을 해제했습니다',
|
||||
archivesProjectMissing: '일부 아카이브가 더 이상 존재하지 않는 프로젝트를 참조했습니다 - 연결을 해제했습니다',
|
||||
archivesOwnerCleared: '일부 아카이브가 더 이상 존재하지 않는 사용자를 참조했습니다 - 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
|
||||
archivesOwnerUnmatched: '일부 아카이브가 이 인스턴스에 없는 소유자를 가리킵니다 - 백업의 사용자 ID로 추측하지 않고 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
|
||||
archivesOwnerUnknown: '일부 아카이브가 소유자 없이 복원되었습니다 - 이 백업에 소유자가 기록되어 있지 않으므로 관리자가 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
|
||||
archivesUndeleted: '백업 이후 삭제된 아카이브가 다시 표시됩니다 - 덮어쓰기가 켜져 있었습니다',
|
||||
archivesMetadataOnly: '복원된 아카이브에는 메타데이터만 있습니다 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
|
||||
spoolUsageUnresolved: '사용 기록 {{count}}건을 건너뛰었습니다 - 해당 스풀이 이 백업의 스풀 목록에 없어 연결할 대상이 없습니다.',
|
||||
spoolUsageUnlinked: '사용 기록 {{count}}건을 출력 기록 연결 없이 복원했습니다 - 연결을 유지하려면 스풀 재고와 함께 출력 아카이브도 선택하세요.',
|
||||
spoolTagKept: '스풀 태그 {{count}}개를 그대로 두었습니다 - 백업대로라면 그 사이 스캔된 태그가 지워지거나 다른 스풀로 옮겨졌을 것입니다.',
|
||||
settingsCredentialsSkipped: '자격 증명처럼 보이는 키 {{count}}개를 건너뛰었습니다 - 비밀 값은 직접 다시 입력하세요',
|
||||
settingsAuthSkipped: '인증 설정 {{count}}개를 건너뛰었습니다 - 잠금 검사가 계속 동작하도록 설정 > 인증에서 변경하세요',
|
||||
settingsCompanionSkipped: '{{keys}}을(를) 꺼진 상태로 두었습니다 - 각 항목에 필요한 자격 증명은 백업에서 복원할 수 없고 이 인스턴스에도 저장되어 있지 않아, 켜면 연동이 인증 없이 열립니다',
|
||||
settingsMqttRelayFailed: 'MQTT 설정을 복원했지만 릴레이를 다시 연결하지 못했습니다 - Bambuddy를 재시작하세요',
|
||||
kprofilesAlwaysOverwrite: 'K 프로파일은 항상 프린터의 해당 슬롯을 덮어씁니다',
|
||||
kprofilesAckUnreliable: '응답하지 않는 프린터도 복원됨으로 집계됩니다 - 프린터에서 프로파일을 확인하세요',
|
||||
kprofilesPrinterMissing: '시리얼 {{serial}}인 프린터가 없습니다 - 건너뛰었습니다',
|
||||
kprofilesPrinterOffline: '{{printer}}({{serial}})이(가) 연결되어 있지 않습니다 - 건너뛰었습니다',
|
||||
kprofilesUnknownNozzle: '{{serial}}의 예상치 못한 노즐 직경 {{nozzle}} - 그대로 전송했습니다',
|
||||
kprofilesUnmatched: '{{nozzle}}용 프로파일 {{count}}개가 {{printer}}에 대응 항목이 없습니다 - 새 프로파일로 추가했습니다',
|
||||
kprofilesSendFailed: '{{nozzle}} 프로파일을 {{printer}}({{serial}})에 보내지 못했습니다',
|
||||
kprofilesRefused: '{{printer}}({{serial}})이(가) {{nozzle}} 프로파일을 거부했습니다: {{reason}}',
|
||||
kprofilesStepFailed: 'K 프로파일 단계를 완료하지 못했습니다 - {{reason}}. 그 전에 복원된 항목은 그대로 저장되어 있습니다.',
|
||||
},
|
||||
},
|
||||
history: '기록',
|
||||
clear: '초기화',
|
||||
date: '날짜',
|
||||
status: '상태',
|
||||
trigger: '유형',
|
||||
triggers: {
|
||||
manual: '백업(수동)',
|
||||
scheduled: '백업(예약)',
|
||||
restore: '복원',
|
||||
},
|
||||
commit: '커밋',
|
||||
localBackup: '로컬 백업',
|
||||
localBackupDescription: '데이터베이스, 아카이브, 업로드 및 모든 파일을 포함한 Bambuddy 데이터의 전체 백업을 만듭니다.',
|
||||
|
||||
@@ -3943,6 +3943,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: 'Projeto principal',
|
||||
parentNone: 'Nenhum (projeto de nível superior)',
|
||||
parentHint: 'Aninhe este projeto em outro para que seus números sejam somados em um projeto mestre',
|
||||
partOf: 'Parte de {{name}}',
|
||||
subProjectCount: '{{count}} sub-projetos',
|
||||
subProjectsOf: 'Sub-projetos de {{name}}',
|
||||
title: 'Projetos',
|
||||
subtitle: 'Organize e acompanhe seus projetos de impressão 3D',
|
||||
newProject: 'Novo Projeto',
|
||||
@@ -4090,6 +4096,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'Sub-projetos ({{count}})',
|
||||
jobs: '{{count}} trabalhos',
|
||||
},
|
||||
rollup: {
|
||||
title: 'Incluindo {{count}} sub-projetos',
|
||||
progress: 'Progresso geral',
|
||||
percentComplete: '{{percent}}% concluído',
|
||||
},
|
||||
notes: {
|
||||
title: 'Notas',
|
||||
@@ -4929,7 +4941,10 @@ export default {
|
||||
personalAccessToken: 'Token de acesso pessoal',
|
||||
tokenSaved: '(salvo)',
|
||||
enterNewToken: 'Digite um novo token para atualizar',
|
||||
tokenHint: 'Token de granularidade fina com permissão de leitura/escrita de conteúdo',
|
||||
tokenHintGitHub: 'Token de granularidade fina com acesso de leitura e escrita a Contents, ou token clássico com o escopo repo.',
|
||||
tokenHintGitLab: 'Token com o escopo api, ou read_repository junto com write_repository.',
|
||||
tokenHintGitea: 'Token com o escopo write:repository.',
|
||||
tokenHintForgejo: 'Token com o escopo write:repository. Um token limitado somente a este repositório já basta.',
|
||||
branch: 'Branch',
|
||||
provider: 'Provedor Git',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -4975,11 +4990,80 @@ export default {
|
||||
clearedLogs: '{{count}} logs removidos',
|
||||
failedToClearLogs: 'Falha ao limpar logs: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Restaurar do Git',
|
||||
title: 'Restaurar do backup Git',
|
||||
subtitle: 'Escolha um commit e o que deve ser restaurado',
|
||||
commitLabel: 'Commit do backup',
|
||||
latestCommit: 'Backup mais recente (ponta do branch)',
|
||||
categoriesLabel: 'O que restaurar',
|
||||
inspecting: 'Lendo o conteúdo do backup...',
|
||||
itemCount: '{{count}} no backup',
|
||||
overwriteLabel: 'Sobrescrever entradas existentes',
|
||||
overwriteOn: 'As entradas existentes serão atualizadas a partir do backup.',
|
||||
overwriteOff: 'Apenas as entradas ausentes são adicionadas; as existentes permanecem intactas.',
|
||||
selectedCount: '{{count}} selecionados',
|
||||
restoring: 'Restaurando...',
|
||||
confirmTitle: 'Restaurar do backup?',
|
||||
confirmMessage: 'As categorias selecionadas serão restauradas deste commit. As entradas ausentes são adicionadas e as existentes permanecem como estão.',
|
||||
confirmMessageOverwrite: 'As categorias selecionadas serão restauradas deste commit, sobrescrevendo as entradas que já existem localmente. Não é possível desfazer.',
|
||||
kprofilesOverwriteCaveat: 'Os perfis K são a exceção: gravar um slot sempre substitui a calibração na impressora.',
|
||||
tally: '{{restored}} restaurados, {{skipped}} ignorados, {{failed}} com falha',
|
||||
reloadHint: 'Recarregue o Bambuddy para que os dados restaurados apareçam em todos os lugares.',
|
||||
partialHint: 'As categorias listadas acima foram concluídas e estão salvas. As que faltam não chegaram a ser executadas.',
|
||||
failed: 'Falha na restauração.',
|
||||
loadFailed: 'Não foi possível ler o repositório de backup.',
|
||||
details: {
|
||||
notPresent: 'Não está presente neste commit de backup',
|
||||
unreadableJson: 'JSON ilegível: {{paths}}',
|
||||
settingsNoPayload: 'Nenhuma configuração nos dados',
|
||||
settingsCredentialsWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas',
|
||||
settingsCompanionWillSkip: '{{count}} chaves parecidas com credenciais serão ignoradas, e {{companion}} chaves que dependem delas ficarão desligadas',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} chaves ficarão desligadas - a credencial que cada uma precisa não pode ser restaurada de um backup',
|
||||
spoolsUsageCount: 'incluindo {{count}} registros de consumo',
|
||||
archivesMetadataOnly: 'Somente metadados - arquivos 3MF e miniaturas não ficam em um backup Git',
|
||||
kprofilesPrinterCount: 'em {{count}} impressoras',
|
||||
},
|
||||
notes: {
|
||||
noData: 'Não há dados desse tipo neste backup',
|
||||
archivesPrinterMissing: 'Alguns arquivos referenciavam impressoras que não existem mais - vínculo removido',
|
||||
archivesProjectMissing: 'Alguns arquivos referenciavam projetos que não existem mais - vínculo removido',
|
||||
archivesOwnerCleared: 'Alguns arquivos referenciavam usuários que não existem mais - o proprietário foi limpo, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua',
|
||||
archivesOwnerUnmatched: 'Alguns arquivos apontam para um proprietário que esta instância não tem - o proprietário foi limpo em vez de deduzido do id de usuário da cópia, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua',
|
||||
archivesOwnerUnknown: 'Alguns arquivos foram restaurados sem proprietário - este backup não registra nenhum, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os atribua',
|
||||
archivesUndeleted: 'Arquivos excluídos desde o backup voltaram a ficar visíveis - a sobrescrita estava ligada',
|
||||
archivesMetadataOnly: 'Os arquivos restaurados contêm apenas metadados - os arquivos 3MF e as miniaturas não ficam em um backup Git',
|
||||
spoolUsageUnresolved: '{{count}} registros de consumo ignorados - o carretel deles não está na lista de carretéis deste backup, então não há a que vinculá-los.',
|
||||
spoolUsageUnlinked: '{{count}} registros de consumo restaurados sem o vínculo com o histórico de impressão - selecione Arquivos de impressão junto com Inventário de carretéis para mantê-lo.',
|
||||
spoolTagKept: '{{count}} etiquetas de carretel foram mantidas como estavam - o backup teria apagado uma etiqueta lida desde então, ou a teria movido para um segundo carretel.',
|
||||
settingsCredentialsSkipped: '{{count}} chaves parecidas com credenciais ignoradas - digite os segredos novamente à mão',
|
||||
settingsAuthSkipped: '{{count}} configurações de autenticação ignoradas - altere-as em Configurações > Autenticação para que as verificações de bloqueio continuem valendo',
|
||||
settingsCompanionSkipped: '{{keys}} ficaram desligados - a credencial que cada um precisa não pode ser restaurada de um backup e esta instância não tem nenhuma armazenada, então ligá-los deixaria a integração sem autenticação',
|
||||
settingsMqttRelayFailed: 'Configurações MQTT restauradas, mas o relay não pôde ser reconectado - reinicie o Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'Os perfis K sempre sobrescrevem o slot correspondente na impressora',
|
||||
kprofilesAckUnreliable: 'Uma impressora que não responde ainda conta como restaurada - verifique os perfis na impressora',
|
||||
kprofilesPrinterMissing: 'Nenhuma impressora com o número de série {{serial}} - ignorado',
|
||||
kprofilesPrinterOffline: '{{printer}} ({{serial}}) não está conectada - ignorado',
|
||||
kprofilesUnknownNozzle: 'Diâmetro de bico inesperado {{nozzle}} para {{serial}} - enviado como está',
|
||||
kprofilesUnmatched: '{{count}} perfis para {{nozzle}} não tinham correspondente em {{printer}} - adicionados como novos perfis',
|
||||
kprofilesSendFailed: 'Não foi possível enviar os perfis de {{nozzle}} para {{printer}} ({{serial}})',
|
||||
kprofilesRefused: '{{printer}} ({{serial}}) recusou os perfis de {{nozzle}}: {{reason}}',
|
||||
kprofilesStepFailed: 'Não foi possível concluir a etapa dos perfis K - {{reason}}. O que foi restaurado antes continua salvo.',
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
history: 'Histórico',
|
||||
clear: 'Limpar',
|
||||
date: 'Data',
|
||||
status: 'Status',
|
||||
trigger: 'Tipo',
|
||||
triggers: {
|
||||
manual: 'Backup manual',
|
||||
scheduled: 'Backup agendado',
|
||||
restore: 'Restauração',
|
||||
},
|
||||
commit: 'Commit',
|
||||
|
||||
// Local Backup
|
||||
|
||||
@@ -3754,6 +3754,12 @@ export default {
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
parentLabel: "Родительский проект",
|
||||
parentNone: "Нет (проект верхнего уровня)",
|
||||
parentHint: "Вложите этот проект в другой, чтобы его показатели суммировались в главном проекте",
|
||||
partOf: "Часть проекта {{name}}",
|
||||
subProjectCount: "Подпроектов: {{count}}",
|
||||
subProjectsOf: "Подпроекты проекта {{name}}",
|
||||
title: "Проекты",
|
||||
subtitle: "Организация и отслеживание проектов 3D-печати",
|
||||
newProject: "Новый проект",
|
||||
@@ -3893,6 +3899,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: "Подпроекты ({{count}})",
|
||||
jobs: "Заданий: {{count}}",
|
||||
},
|
||||
rollup: {
|
||||
title: "Включая подпроекты: {{count}}",
|
||||
progress: "Общий прогресс",
|
||||
percentComplete: "Завершено {{percent}}%",
|
||||
},
|
||||
notes: {
|
||||
title: "Заметки",
|
||||
@@ -4696,7 +4708,10 @@ export default {
|
||||
personalAccessToken: "Персональный токен доступа",
|
||||
tokenSaved: "(сохранён)",
|
||||
enterNewToken: "Введите новый токен для обновления",
|
||||
tokenHint: "Токен с точечными правами на чтение и запись содержимого",
|
||||
tokenHintGitHub: "Токен с точечными правами на чтение и запись Contents либо классический токен с областью repo.",
|
||||
tokenHintGitLab: "Токен с областью api либо read_repository вместе с write_repository.",
|
||||
tokenHintGitea: "Токен с областью write:repository.",
|
||||
tokenHintForgejo: "Токен с областью write:repository. Достаточно токена, ограниченного только этим репозиторием.",
|
||||
branch: "Ветка",
|
||||
provider: "Git-провайдер",
|
||||
providerGitHub: "GitHub",
|
||||
@@ -4741,10 +4756,79 @@ export default {
|
||||
backupFailed2: "Ошибка резервного копирования: {{message}}",
|
||||
clearedLogs: "Очищено записей журнала: {{count}}",
|
||||
failedToClearLogs: "Не удалось очистить журнал: {{message}}",
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Восстановить из Git',
|
||||
title: 'Восстановление из резервной копии Git',
|
||||
subtitle: 'Выберите коммит и данные для восстановления',
|
||||
commitLabel: 'Коммит резервной копии',
|
||||
latestCommit: 'Последняя резервная копия (вершина ветки)',
|
||||
categoriesLabel: 'Что восстановить',
|
||||
inspecting: 'Чтение содержимого резервной копии...',
|
||||
itemCount: '{{count}} в резервной копии',
|
||||
overwriteLabel: 'Перезаписывать существующие записи',
|
||||
overwriteOn: 'Существующие записи будут обновлены из резервной копии.',
|
||||
overwriteOff: 'Добавляются только отсутствующие записи, существующие не изменяются.',
|
||||
selectedCount: 'Выбрано: {{count}}',
|
||||
restoring: 'Восстановление...',
|
||||
confirmTitle: 'Восстановить из резервной копии?',
|
||||
confirmMessage: 'Выбранные категории будут восстановлены из этого коммита. Отсутствующие записи будут добавлены, существующие останутся без изменений.',
|
||||
confirmMessageOverwrite: 'Выбранные категории будут восстановлены из этого коммита с перезаписью уже существующих локальных записей. Отменить это действие нельзя.',
|
||||
kprofilesOverwriteCaveat: 'K-профили — исключение: запись в слот всегда заменяет калибровку на принтере.',
|
||||
tally: 'восстановлено: {{restored}}, пропущено: {{skipped}}, с ошибкой: {{failed}}',
|
||||
reloadHint: 'Перезагрузите Bambuddy, чтобы восстановленные данные отобразились везде.',
|
||||
partialHint: 'Перечисленные выше категории завершены и сохранены. Отсутствующие категории не выполнялись.',
|
||||
failed: 'Не удалось выполнить восстановление.',
|
||||
loadFailed: 'Не удалось прочитать репозиторий резервных копий.',
|
||||
details: {
|
||||
notPresent: 'Отсутствует в этом коммите резервной копии',
|
||||
unreadableJson: 'Нечитаемый JSON: {{paths}}',
|
||||
settingsNoPayload: 'В данных нет настроек',
|
||||
settingsCredentialsWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}',
|
||||
settingsCompanionWillSkip: 'Ключей, похожих на учётные данные, будет пропущено: {{count}}, а зависящие от них переключатели ({{companion}}) останутся выключенными',
|
||||
settingsCompanionOnlyWillSkip: 'Переключатели ({{companion}}) останутся выключенными - учётные данные, нужные каждому из них, нельзя восстановить из резервной копии',
|
||||
spoolsUsageCount: 'включая записей расхода: {{count}}',
|
||||
archivesMetadataOnly: 'Только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
|
||||
kprofilesPrinterCount: 'по {{count}} принтерам',
|
||||
},
|
||||
notes: {
|
||||
noData: 'В этой резервной копии нет данных такого типа',
|
||||
archivesPrinterMissing: 'Некоторые архивы ссылались на несуществующие принтеры - связь очищена',
|
||||
archivesProjectMissing: 'Некоторые архивы ссылались на несуществующие проекты - связь очищена',
|
||||
archivesOwnerCleared: 'Некоторые архивы ссылались на несуществующих пользователей - владелец очищен, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново',
|
||||
archivesOwnerUnmatched: 'Некоторые архивы указывают владельца, которого нет в этом экземпляре - владелец очищен, а не угадан по идентификатору пользователя из резервной копии, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново',
|
||||
archivesOwnerUnknown: 'Некоторые архивы восстановлены без владельца - в этой резервной копии он не записан, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца',
|
||||
archivesUndeleted: 'Архивы, удалённые после резервного копирования, снова видны - перезапись была включена',
|
||||
archivesMetadataOnly: 'Восстановленные архивы содержат только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
|
||||
spoolUsageUnresolved: 'Записей расхода пропущено: {{count}} - их катушки нет в списке катушек этой резервной копии, поэтому привязать их не к чему.',
|
||||
spoolUsageUnlinked: 'Записей расхода восстановлено без связи с историей печати: {{count}} - выберите «Архивы печати» вместе с «Инвентарём катушек», чтобы сохранить связь.',
|
||||
spoolTagKept: 'Меток катушек оставлено без изменений: {{count}} - резервная копия стёрла бы метку, отсканированную позже, или перенесла бы её на другую катушку.',
|
||||
settingsCredentialsSkipped: 'Ключей, похожих на учётные данные, пропущено: {{count}} - введите секреты вручную',
|
||||
settingsAuthSkipped: 'Настроек аутентификации пропущено: {{count}} - меняйте их в разделе «Настройки > Аутентификация», чтобы продолжали работать проверки блокировки',
|
||||
settingsCompanionSkipped: '{{keys}} оставлены выключенными - нужные им учётные данные нельзя восстановить из резервной копии, и в этом экземпляре они не сохранены, поэтому включение оставило бы интеграцию без аутентификации',
|
||||
settingsMqttRelayFailed: 'Настройки MQTT восстановлены, но переподключить реле не удалось - перезапустите Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'K-профили всегда перезаписывают соответствующий слот на принтере',
|
||||
kprofilesAckUnreliable: 'Принтер, который не отвечает, всё равно считается восстановленным - проверьте профили на принтере',
|
||||
kprofilesPrinterMissing: 'Нет принтера с серийным номером {{serial}} - пропущено',
|
||||
kprofilesPrinterOffline: '{{printer}} ({{serial}}) не подключён - пропущено',
|
||||
kprofilesUnknownNozzle: 'Неожиданный диаметр сопла {{nozzle}} для {{serial}} - отправлено как есть',
|
||||
kprofilesUnmatched: 'Профилей для {{nozzle}} без соответствия на {{printer}}: {{count}} - добавлены как новые профили',
|
||||
kprofilesSendFailed: 'Не удалось отправить профили {{nozzle}} на {{printer}} ({{serial}})',
|
||||
kprofilesRefused: '{{printer}} ({{serial}}) отклонил профили {{nozzle}}: {{reason}}',
|
||||
kprofilesStepFailed: 'Не удалось завершить этап K-профилей - {{reason}}. Всё, что было восстановлено до него, сохранено.',
|
||||
},
|
||||
},
|
||||
history: "История",
|
||||
clear: "Очистить",
|
||||
date: "Дата",
|
||||
status: "Статус",
|
||||
trigger: "Тип",
|
||||
triggers: {
|
||||
manual: "Резервная копия (вручную)",
|
||||
scheduled: "Резервная копия (по расписанию)",
|
||||
restore: "Восстановление",
|
||||
},
|
||||
commit: "Коммит",
|
||||
localBackup: "Локальная резервная копия",
|
||||
localBackupDescription: "Создать полную резервную копию данных Bambuddy, включая базу данных, архивы, загрузки и все файлы.",
|
||||
|
||||
@@ -3950,6 +3950,12 @@ export default {
|
||||
|
||||
// Projeler
|
||||
projects: {
|
||||
parentLabel: 'Üst proje',
|
||||
parentNone: 'Yok (üst düzey proje)',
|
||||
parentHint: 'Bu projeyi başka bir projenin altına yerleştirin, böylece rakamları ana projede toplanır',
|
||||
partOf: '{{name}} projesinin parçası',
|
||||
subProjectCount: '{{count}} alt proje',
|
||||
subProjectsOf: '{{name}} alt projeleri',
|
||||
title: 'Projeler',
|
||||
subtitle: '3D baskı projelerinizi organize edin ve takip edin',
|
||||
newProject: 'Yeni Proje',
|
||||
@@ -4091,6 +4097,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: 'Alt projeler ({{count}})',
|
||||
jobs: '{{count}} iş',
|
||||
},
|
||||
rollup: {
|
||||
title: '{{count}} alt proje dahil',
|
||||
progress: 'Genel ilerleme',
|
||||
percentComplete: '%{{percent}} tamamlandı',
|
||||
},
|
||||
notes: {
|
||||
title: 'Notlar',
|
||||
@@ -4918,7 +4930,10 @@ export default {
|
||||
personalAccessToken: 'Kişisel Erişim Belirteci',
|
||||
tokenSaved: '(kaydedildi)',
|
||||
enterNewToken: 'Güncellemek için yeni belirteç girin',
|
||||
tokenHint: 'Contents okuma/yazma izni olan ayrıntılı belirteç',
|
||||
tokenHintGitHub: 'Contents okuma ve yazma erişimi olan ayrıntılı belirteç ya da repo kapsamlı klasik belirteç.',
|
||||
tokenHintGitLab: 'api kapsamlı belirteç ya da read_repository ile write_repository birlikte.',
|
||||
tokenHintGitea: 'write:repository kapsamlı belirteç.',
|
||||
tokenHintForgejo: 'write:repository kapsamlı belirteç. Yalnızca bu depoyla sınırlı bir belirteç yeterlidir.',
|
||||
branch: 'Dal',
|
||||
provider: 'Git Sağlayıcısı',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -4964,10 +4979,79 @@ export default {
|
||||
clearedLogs: '{{count}} günlük temizlendi',
|
||||
failedToClearLogs: 'Günlükler temizlenemedi: {{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: 'Git\'ten Geri Yükle',
|
||||
title: 'Git yedeğinden geri yükle',
|
||||
subtitle: 'Bir commit ve geri yüklenecek verileri seçin',
|
||||
commitLabel: 'Yedek commit\'i',
|
||||
latestCommit: 'En son yedek (dal ucu)',
|
||||
categoriesLabel: 'Neler geri yüklenecek',
|
||||
inspecting: 'Yedek içeriği okunuyor...',
|
||||
itemCount: 'yedekte {{count}} kayıt',
|
||||
overwriteLabel: 'Mevcut kayıtların üzerine yaz',
|
||||
overwriteOn: 'Mevcut kayıtlar yedekten güncellenecek.',
|
||||
overwriteOff: 'Yalnızca eksik kayıtlar eklenir; mevcut olanlara dokunulmaz.',
|
||||
selectedCount: '{{count}} seçildi',
|
||||
restoring: 'Geri yükleniyor...',
|
||||
confirmTitle: 'Yedekten geri yüklensin mi?',
|
||||
confirmMessage: 'Seçilen kategoriler bu commit\'ten geri yüklenecek. Eksik kayıtlar eklenir, mevcut kayıtlar olduğu gibi kalır.',
|
||||
confirmMessageOverwrite: 'Seçilen kategoriler bu commit\'ten geri yüklenecek ve yerelde bulunan kayıtların üzerine yazılacak. Bu işlem geri alınamaz.',
|
||||
kprofilesOverwriteCaveat: 'K profilleri istisnadır: bir yuvaya yazmak yazıcıdaki kalibrasyonu her zaman değiştirir.',
|
||||
tally: '{{restored}} geri yüklendi, {{skipped}} atlandı, {{failed}} başarısız',
|
||||
reloadHint: 'Geri yüklenen verilerin her yerde görünmesi için Bambuddy\'yi yeniden yükleyin.',
|
||||
partialHint: 'Yukarıda listelenen kategoriler tamamlandı ve kaydedildi. Eksik olanlar hiç çalıştırılmadı.',
|
||||
failed: 'Geri yükleme başarısız oldu.',
|
||||
loadFailed: 'Yedek deposu okunamadı.',
|
||||
details: {
|
||||
notPresent: 'Bu yedek commit\'inde yok',
|
||||
unreadableJson: 'Okunamayan JSON: {{paths}}',
|
||||
settingsNoPayload: 'Veride ayar yok',
|
||||
settingsCredentialsWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak',
|
||||
settingsCompanionWillSkip: 'Kimlik bilgisi benzeri {{count}} anahtar atlanacak ve bunlara bağlı {{companion}} anahtar kapalı bırakılacak',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} anahtar kapalı bırakılacak - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez',
|
||||
spoolsUsageCount: '{{count}} kullanım kaydı dahil',
|
||||
archivesMetadataOnly: 'Yalnızca üst veri - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
|
||||
kprofilesPrinterCount: '{{count}} yazıcı genelinde',
|
||||
},
|
||||
notes: {
|
||||
noData: 'Bu yedekte bu türde veri yok',
|
||||
archivesPrinterMissing: 'Bazı arşivler artık var olmayan yazıcılara işaret ediyordu - bağlantı temizlendi',
|
||||
archivesProjectMissing: 'Bazı arşivler artık var olmayan projelere işaret ediyordu - bağlantı temizlendi',
|
||||
archivesOwnerCleared: 'Bazı arşivler artık var olmayan kullanıcılara işaret ediyordu - sahip temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
|
||||
archivesOwnerUnmatched: 'Bazı arşivler bu örnekte bulunmayan bir sahibi belirtiyor - sahip, yedekteki kullanıcı kimliğinden tahmin edilmek yerine temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
|
||||
archivesOwnerUnknown: 'Bazı arşivler sahipsiz geri yüklendi - bu yedek sahip bilgisi içermiyor, bu yüzden bir yönetici atama yapana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
|
||||
archivesUndeleted: 'Yedekten sonra silinen arşivler yeniden görünür oldu - üzerine yazma açıktı',
|
||||
archivesMetadataOnly: 'Geri yüklenen arşivler yalnızca üst veri içerir - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
|
||||
spoolUsageUnresolved: '{{count}} kullanım kaydı atlandı - makaraları bu yedeğin makara listesinde olmadığı için bağlanacak bir şey yok.',
|
||||
spoolUsageUnlinked: '{{count}} kullanım kaydı baskı geçmişi bağlantısı olmadan geri yüklendi - bağlantıyı korumak için Baskı arşivlerini Makara envanteriyle birlikte seçin.',
|
||||
spoolTagKept: '{{count}} makara etiketi olduğu gibi bırakıldı - yedek, o zamandan beri okutulmuş bir etiketi silecek ya da ikinci bir makaraya taşıyacaktı.',
|
||||
settingsCredentialsSkipped: 'Kimlik bilgisi benzeri {{count}} anahtar atlandı - gizli değerleri elle yeniden girin',
|
||||
settingsAuthSkipped: '{{count}} kimlik doğrulama ayarı atlandı - kilitlenme kontrolleri çalışmaya devam etsin diye bunları Ayarlar > Kimlik Doğrulama bölümünden değiştirin',
|
||||
settingsCompanionSkipped: '{{keys}} kapalı bırakıldı - her birinin ihtiyaç duyduğu kimlik bilgisi bir yedekten geri yüklenemez ve bu örnekte kayıtlı değil, dolayısıyla açmak entegrasyonu kimlik doğrulamasız bırakırdı',
|
||||
settingsMqttRelayFailed: 'MQTT ayarları geri yüklendi ancak röle yeniden bağlanamadı - Bambuddy\'yi yeniden başlatın',
|
||||
kprofilesAlwaysOverwrite: 'K profilleri yazıcıdaki eşleşen yuvanın her zaman üzerine yazar',
|
||||
kprofilesAckUnreliable: 'Yanıt vermeyen bir yazıcı yine de geri yüklendi sayılır - profilleri yazıcıda doğrulayın',
|
||||
kprofilesPrinterMissing: '{{serial}} seri numaralı yazıcı yok - atlandı',
|
||||
kprofilesPrinterOffline: '{{printer}} ({{serial}}) bağlı değil - atlandı',
|
||||
kprofilesUnknownNozzle: '{{serial}} için beklenmeyen nozul çapı {{nozzle}} - olduğu gibi gönderildi',
|
||||
kprofilesUnmatched: '{{nozzle}} için {{count}} profilin {{printer}} üzerinde karşılığı yoktu - yeni profil olarak eklendi',
|
||||
kprofilesSendFailed: '{{nozzle}} profilleri {{printer}} ({{serial}}) yazıcısına gönderilemedi',
|
||||
kprofilesRefused: '{{printer}} ({{serial}}) {{nozzle}} profillerini reddetti: {{reason}}',
|
||||
kprofilesStepFailed: 'K profili adımı tamamlanamadı - {{reason}}. Bundan önce geri yüklenenler yine de kaydedildi.',
|
||||
},
|
||||
},
|
||||
|
||||
history: 'Geçmiş',
|
||||
clear: 'Temizle',
|
||||
date: 'Tarih',
|
||||
status: 'Durum',
|
||||
trigger: 'Tür',
|
||||
triggers: {
|
||||
manual: 'Yedek (manuel)',
|
||||
scheduled: 'Yedek (zamanlanmış)',
|
||||
restore: 'Geri yükleme',
|
||||
},
|
||||
commit: 'Commit',
|
||||
|
||||
// Yerel Yedekleme
|
||||
|
||||
@@ -3983,6 +3983,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: "Батьківський проєкт",
|
||||
parentNone: "Немає (проєкт верхнього рівня)",
|
||||
parentHint: "Вкладіть цей проєкт в інший, щоб його показники підсумовувалися в головному проєкті",
|
||||
partOf: "Частина проєкту {{name}}",
|
||||
subProjectCount: "Підпроєктів: {{count}}",
|
||||
subProjectsOf: "Підпроєкти проєкту {{name}}",
|
||||
title: "Проєкти",
|
||||
subtitle: "Організовуйте та відстежуйте свої проєкти 3D-друку",
|
||||
newProject: "Новий проєкт",
|
||||
@@ -4130,6 +4136,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: "Підпроєкти ({{count}})",
|
||||
jobs: "Завдань: {{count}}",
|
||||
},
|
||||
rollup: {
|
||||
title: "Разом із підпроєктами: {{count}}",
|
||||
progress: "Загальний прогрес",
|
||||
percentComplete: "Завершено {{percent}}%",
|
||||
},
|
||||
notes: {
|
||||
title: "Примітки",
|
||||
@@ -4983,7 +4995,10 @@ export default {
|
||||
personalAccessToken: "Персональний токен доступу",
|
||||
tokenSaved: "(збережено)",
|
||||
enterNewToken: "Введіть новий токен для оновлення",
|
||||
tokenHint: "Токен із деталізованими правами та дозволом на читання й запис вмісту",
|
||||
tokenHintGitHub: "Токен із деталізованими правами на читання й запис Contents або класичний токен з областю repo.",
|
||||
tokenHintGitLab: "Токен з областю api або read_repository разом із write_repository.",
|
||||
tokenHintGitea: "Токен з областю write:repository.",
|
||||
tokenHintForgejo: "Токен з областю write:repository. Достатньо токена, обмеженого лише цим репозиторієм.",
|
||||
branch: "Гілка",
|
||||
provider: "Постачальник Git",
|
||||
providerGitHub: "GitHub",
|
||||
@@ -5029,11 +5044,80 @@ export default {
|
||||
clearedLogs: "Очищено журнали {{count}}.",
|
||||
failedToClearLogs: "Не вдалося очистити журнали: {{message}}",
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: "Відновити з Git",
|
||||
title: "Відновлення з резервної копії Git",
|
||||
subtitle: "Виберіть коміт і вкажіть, що відновити",
|
||||
commitLabel: "Коміт резервної копії",
|
||||
latestCommit: "Остання резервна копія (вершина гілки)",
|
||||
categoriesLabel: "Що відновити",
|
||||
inspecting: "Читання вмісту резервної копії...",
|
||||
itemCount: "{{count}} у резервній копії",
|
||||
overwriteLabel: "Перезаписувати наявні записи",
|
||||
overwriteOn: "Наявні записи буде оновлено з резервної копії.",
|
||||
overwriteOff: "Додаються лише відсутні записи; наявні залишаються без змін.",
|
||||
selectedCount: "Вибрано: {{count}}",
|
||||
restoring: "Відновлення...",
|
||||
confirmTitle: "Відновити з резервної копії?",
|
||||
confirmMessage: "Вибрані категорії буде відновлено з цього коміту. Відсутні записи буде додано, наявні залишаться без змін.",
|
||||
confirmMessageOverwrite: "Вибрані категорії буде відновлено з цього коміту з перезаписом записів, які вже існують локально. Цю дію не можна скасувати.",
|
||||
kprofilesOverwriteCaveat: 'K-профілі — виняток: запис у слот завжди замінює калібрування на принтері.',
|
||||
tally: "відновлено: {{restored}}, пропущено: {{skipped}}, з помилкою: {{failed}}",
|
||||
reloadHint: "Перезавантажте Bambuddy, щоб відновлені дані відобразилися всюди.",
|
||||
partialHint: 'Перелічені вище категорії завершено та збережено. Відсутні категорії не виконувалися.',
|
||||
failed: "Не вдалося виконати відновлення.",
|
||||
loadFailed: "Не вдалося прочитати репозиторій резервних копій.",
|
||||
details: {
|
||||
notPresent: "Відсутнє в цьому коміті резервної копії",
|
||||
unreadableJson: "Нечитабельний JSON: {{paths}}",
|
||||
settingsNoPayload: "У даних немає налаштувань",
|
||||
settingsCredentialsWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}",
|
||||
settingsCompanionWillSkip: "Ключів, схожих на облікові дані, буде пропущено: {{count}}, а залежні від них перемикачі ({{companion}}) залишаться вимкненими",
|
||||
settingsCompanionOnlyWillSkip: 'Перемикачі ({{companion}}) залишаться вимкненими - облікові дані, потрібні кожному з них, не можна відновити з резервної копії',
|
||||
spoolsUsageCount: "включно із записами використання: {{count}}",
|
||||
archivesMetadataOnly: "Лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
|
||||
kprofilesPrinterCount: "по {{count}} принтерах",
|
||||
},
|
||||
notes: {
|
||||
noData: "У цій резервній копії немає даних такого типу",
|
||||
archivesPrinterMissing: "Деякі архіви посилалися на принтери, яких більше немає - зв'язок очищено",
|
||||
archivesProjectMissing: "Деякі архіви посилалися на проєкти, яких більше немає - зв'язок очищено",
|
||||
archivesOwnerCleared: "Деякі архіви посилалися на користувачів, яких більше немає - власника очищено, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову",
|
||||
archivesOwnerUnmatched: "Деякі архіви вказують власника, якого немає в цьому екземплярі - власника очищено, а не вгадано за ідентифікатором користувача з резервної копії, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову",
|
||||
archivesOwnerUnknown: "Деякі архіви відновлено без власника - у цій резервній копії його не записано, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника",
|
||||
archivesUndeleted: "Архіви, видалені після резервного копіювання, знову видимі - перезапис був увімкнений",
|
||||
archivesMetadataOnly: "Відновлені архіви містять лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
|
||||
spoolUsageUnresolved: "Записів використання пропущено: {{count}} - їхньої котушки немає у списку котушок цієї резервної копії, тож немає до чого їх прив'язати.",
|
||||
spoolUsageUnlinked: "Записів використання відновлено без зв'язку з історією друку: {{count}} - виберіть «Архіви друку» разом з «Інвентарем котушок», щоб зберегти зв'язок.",
|
||||
spoolTagKept: "Міток котушок залишено без змін: {{count}} - резервна копія стерла б мітку, відскановану пізніше, або перенесла б її на іншу котушку.",
|
||||
settingsCredentialsSkipped: "Ключів, схожих на облікові дані, пропущено: {{count}} - введіть секрети вручну",
|
||||
settingsAuthSkipped: "Налаштувань автентифікації пропущено: {{count}} - змінюйте їх у розділі «Налаштування > Автентифікація», щоб перевірки блокування й далі працювали",
|
||||
settingsCompanionSkipped: "{{keys}} залишено вимкненими - потрібні їм облікові дані не можна відновити з резервної копії, і в цьому екземплярі вони не збережені, тож увімкнення залишило б інтеграцію без автентифікації",
|
||||
settingsMqttRelayFailed: "Налаштування MQTT відновлено, але реле не вдалося перепідключити - перезапустіть Bambuddy",
|
||||
kprofilesAlwaysOverwrite: "K-профілі завжди перезаписують відповідний слот на принтері",
|
||||
kprofilesAckUnreliable: "Принтер, який не відповідає, усе одно вважається відновленим - перевірте профілі на принтері",
|
||||
kprofilesPrinterMissing: "Немає принтера із серійним номером {{serial}} - пропущено",
|
||||
kprofilesPrinterOffline: "{{printer}} ({{serial}}) не підключено - пропущено",
|
||||
kprofilesUnknownNozzle: "Неочікуваний діаметр сопла {{nozzle}} для {{serial}} - надіслано як є",
|
||||
kprofilesUnmatched: "Профілів для {{nozzle}} без відповідника на {{printer}}: {{count}} - додано як нові профілі",
|
||||
kprofilesSendFailed: "Не вдалося надіслати профілі {{nozzle}} на {{printer}} ({{serial}})",
|
||||
kprofilesRefused: "{{printer}} ({{serial}}) відхилив профілі {{nozzle}}: {{reason}}",
|
||||
kprofilesStepFailed: "Не вдалося завершити етап K-профілів - {{reason}}. Усе, що було відновлено до нього, збережено.",
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
history: "історія",
|
||||
clear: "Очистити",
|
||||
date: "Дата",
|
||||
status: "Статус",
|
||||
trigger: "Тип",
|
||||
triggers: {
|
||||
manual: "Резервна копія (вручну)",
|
||||
scheduled: "Резервна копія (за розкладом)",
|
||||
restore: "Відновлення",
|
||||
},
|
||||
commit: "Коміт",
|
||||
|
||||
// Local Backup
|
||||
|
||||
@@ -3943,6 +3943,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: '父项目',
|
||||
parentNone: '无(顶级项目)',
|
||||
parentHint: '将此项目嵌套在另一个项目下,其数据将汇总到主项目中',
|
||||
partOf: '属于 {{name}}',
|
||||
subProjectCount: '{{count}} 个子项目',
|
||||
subProjectsOf: '{{name}} 的子项目',
|
||||
title: '项目',
|
||||
subtitle: '组织和跟踪您的 3D 打印项目',
|
||||
newProject: '新建项目',
|
||||
@@ -4090,6 +4096,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: '子项目 ({{count}})',
|
||||
jobs: '{{count}} 个任务',
|
||||
},
|
||||
rollup: {
|
||||
title: '包含 {{count}} 个子项目',
|
||||
progress: '总体进度',
|
||||
percentComplete: '已完成 {{percent}}%',
|
||||
},
|
||||
notes: {
|
||||
title: '备注',
|
||||
@@ -4929,7 +4941,10 @@ export default {
|
||||
personalAccessToken: '个人访问令牌',
|
||||
tokenSaved: '(已保存)',
|
||||
enterNewToken: '输入新令牌以更新',
|
||||
tokenHint: '具有内容读写权限的细粒度令牌',
|
||||
tokenHintGitHub: '具有 Contents 读写权限的细粒度令牌,或具有 repo 范围的经典令牌。',
|
||||
tokenHintGitLab: '具有 api 范围的令牌,或同时具有 read_repository 和 write_repository。',
|
||||
tokenHintGitea: '具有 write:repository 范围的令牌。',
|
||||
tokenHintForgejo: '具有 write:repository 范围的令牌。仅限于此仓库的令牌即可。',
|
||||
branch: '分支',
|
||||
provider: 'Git 提供商',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -4975,11 +4990,80 @@ export default {
|
||||
clearedLogs: '已清除 {{count}} 条日志',
|
||||
failedToClearLogs: '清除日志失败:{{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: '从 Git 恢复',
|
||||
title: '从 Git 备份恢复',
|
||||
subtitle: '选择提交以及要恢复的内容',
|
||||
commitLabel: '备份提交',
|
||||
latestCommit: '最新备份(分支最新提交)',
|
||||
categoriesLabel: '恢复内容',
|
||||
inspecting: '正在读取备份内容...',
|
||||
itemCount: '备份中有 {{count}} 项',
|
||||
overwriteLabel: '覆盖已有条目',
|
||||
overwriteOn: '已有条目将根据备份内容更新。',
|
||||
overwriteOff: '仅添加缺失的条目,已有条目保持不变。',
|
||||
selectedCount: '已选择 {{count}} 项',
|
||||
restoring: '正在恢复...',
|
||||
confirmTitle: '要从备份恢复吗?',
|
||||
confirmMessage: '将从此提交恢复所选类别。缺失的条目会被添加,已有条目保持不变。',
|
||||
confirmMessageOverwrite: '将从此提交恢复所选类别,并覆盖本地已存在的条目。此操作无法撤销。',
|
||||
kprofilesOverwriteCaveat: 'K 值配置是例外:写入插槽总会替换打印机上的校准数据。',
|
||||
tally: '已恢复 {{restored}} 项,跳过 {{skipped}} 项,失败 {{failed}} 项',
|
||||
reloadHint: '请重新加载 Bambuddy,以便恢复的数据在各处生效。',
|
||||
partialHint: '上面列出的类别已完成并已保存。未列出的类别没有执行。',
|
||||
failed: '恢复失败。',
|
||||
loadFailed: '无法读取备份仓库。',
|
||||
details: {
|
||||
notPresent: '此备份提交中不存在',
|
||||
unreadableJson: '无法解析的 JSON:{{paths}}',
|
||||
settingsNoPayload: '数据中没有设置',
|
||||
settingsCredentialsWillSkip: '将跳过 {{count}} 个疑似凭据的键',
|
||||
settingsCompanionWillSkip: '将跳过 {{count}} 个疑似凭据的键,依赖它们的 {{companion}} 个开关将保持关闭',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} 个开关将保持关闭 - 每个开关所需的凭据无法从备份中恢复',
|
||||
spoolsUsageCount: '其中含 {{count}} 条使用记录',
|
||||
archivesMetadataOnly: '仅元数据 - 3MF 文件和缩略图不在 Git 备份中',
|
||||
kprofilesPrinterCount: '涉及 {{count}} 台打印机',
|
||||
},
|
||||
notes: {
|
||||
noData: '此备份中没有这类数据',
|
||||
archivesPrinterMissing: '部分归档引用了已不存在的打印机 - 已清除关联',
|
||||
archivesProjectMissing: '部分归档引用了已不存在的项目 - 已清除关联',
|
||||
archivesOwnerCleared: '部分归档引用了已不存在的用户 - 已清除归属,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
|
||||
archivesOwnerUnmatched: '部分归档指向本实例没有的用户 - 已清除归属,而不是根据备份中的用户 ID 猜测,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
|
||||
archivesOwnerUnknown: '部分归档在恢复时没有归属 - 此备份未记录归属,因此在管理员指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
|
||||
archivesUndeleted: '备份之后被删除的归档重新可见 - 当时启用了覆盖',
|
||||
archivesMetadataOnly: '恢复的归档仅含元数据 - 3MF 文件和缩略图不在 Git 备份中',
|
||||
spoolUsageUnresolved: '已跳过 {{count}} 条使用记录 - 其耗材卷不在此备份的耗材列表中,没有可挂接的对象。',
|
||||
spoolUsageUnlinked: '已恢复 {{count}} 条使用记录,但缺少打印历史关联 - 请同时选择“打印归档”和“耗材库存”以保留该关联。',
|
||||
spoolTagKept: '{{count}} 个耗材标签保持原样 - 按备份内容会清除此后扫描过的标签,或把它挪到另一卷耗材上。',
|
||||
settingsCredentialsSkipped: '已跳过 {{count}} 个疑似凭据的键 - 请手动重新输入密钥',
|
||||
settingsAuthSkipped: '已跳过 {{count}} 项认证设置 - 请在“设置 > 认证”中修改,以便锁定检查继续生效',
|
||||
settingsCompanionSkipped: '{{keys}} 保持关闭 - 它们各自所需的凭据无法从备份恢复,本实例也没有存储,开启会让集成处于未认证状态',
|
||||
settingsMqttRelayFailed: 'MQTT 设置已恢复,但中继无法重新连接 - 请重启 Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'K 值配置总是覆盖打印机上对应的槽位',
|
||||
kprofilesAckUnreliable: '打印机不回应时仍计为已恢复 - 请在打印机上核对配置',
|
||||
kprofilesPrinterMissing: '没有序列号为 {{serial}} 的打印机 - 已跳过',
|
||||
kprofilesPrinterOffline: '{{printer}}({{serial}})未连接 - 已跳过',
|
||||
kprofilesUnknownNozzle: '{{serial}} 的喷嘴直径 {{nozzle}} 不在预期范围内 - 已原样发送',
|
||||
kprofilesUnmatched: '{{nozzle}} 的 {{count}} 个配置在 {{printer}} 上没有对应项 - 已作为新配置添加',
|
||||
kprofilesSendFailed: '无法将 {{nozzle}} 的配置发送到 {{printer}}({{serial}})',
|
||||
kprofilesRefused: '{{printer}}({{serial}})拒绝了 {{nozzle}} 的配置:{{reason}}',
|
||||
kprofilesStepFailed: 'K 值配置步骤未能完成 - {{reason}}。在此之前恢复的内容仍已保存。',
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
history: '历史记录',
|
||||
clear: '清除',
|
||||
date: '日期',
|
||||
status: '状态',
|
||||
trigger: '类型',
|
||||
triggers: {
|
||||
manual: '备份(手动)',
|
||||
scheduled: '备份(计划)',
|
||||
restore: '恢复',
|
||||
},
|
||||
commit: '提交',
|
||||
|
||||
// Local Backup
|
||||
|
||||
@@ -3943,6 +3943,12 @@ export default {
|
||||
|
||||
// Projects
|
||||
projects: {
|
||||
parentLabel: '父專案',
|
||||
parentNone: '無(頂層專案)',
|
||||
parentHint: '將此專案巢狀置於另一個專案下,其數據將彙總至主專案',
|
||||
partOf: '屬於 {{name}}',
|
||||
subProjectCount: '{{count}} 個子專案',
|
||||
subProjectsOf: '{{name}} 的子專案',
|
||||
title: '專案',
|
||||
subtitle: '組織和追蹤您的 3D 列印專案',
|
||||
newProject: '新增專案',
|
||||
@@ -4090,6 +4096,12 @@ export default {
|
||||
},
|
||||
subProjects: {
|
||||
title: '子專案 ({{count}})',
|
||||
jobs: '{{count}} 個任務',
|
||||
},
|
||||
rollup: {
|
||||
title: '包含 {{count}} 個子專案',
|
||||
progress: '整體進度',
|
||||
percentComplete: '已完成 {{percent}}%',
|
||||
},
|
||||
notes: {
|
||||
title: '備註',
|
||||
@@ -4929,7 +4941,10 @@ export default {
|
||||
personalAccessToken: '個人存取權杖',
|
||||
tokenSaved: '(已儲存)',
|
||||
enterNewToken: '輸入新權杖以更新',
|
||||
tokenHint: '具有內容讀寫權限的細粒度權杖',
|
||||
tokenHintGitHub: '具有 Contents 讀寫權限的細粒度權杖,或具有 repo 範圍的傳統權杖。',
|
||||
tokenHintGitLab: '具有 api 範圍的權杖,或同時具有 read_repository 與 write_repository。',
|
||||
tokenHintGitea: '具有 write:repository 範圍的權杖。',
|
||||
tokenHintForgejo: '具有 write:repository 範圍的權杖。僅限於此存放庫的權杖即可。',
|
||||
branch: '分支',
|
||||
provider: 'Git 供應商',
|
||||
providerGitHub: 'GitHub',
|
||||
@@ -4975,11 +4990,80 @@ export default {
|
||||
clearedLogs: '已清除 {{count}} 條日誌',
|
||||
failedToClearLogs: '清除日誌失敗:{{message}}',
|
||||
|
||||
// Restore from Git backup (#2656)
|
||||
restoreFromGit: {
|
||||
button: '從 Git 還原',
|
||||
title: '從 Git 備份還原',
|
||||
subtitle: '選擇提交以及要還原的項目',
|
||||
commitLabel: '備份提交',
|
||||
latestCommit: '最新備份(分支最新提交)',
|
||||
categoriesLabel: '還原項目',
|
||||
inspecting: '正在讀取備份內容...',
|
||||
itemCount: '備份中有 {{count}} 筆',
|
||||
overwriteLabel: '覆寫既有項目',
|
||||
overwriteOn: '既有項目將依備份內容更新。',
|
||||
overwriteOff: '僅新增缺少的項目,既有項目保持不變。',
|
||||
selectedCount: '已選擇 {{count}} 筆',
|
||||
restoring: '正在還原...',
|
||||
confirmTitle: '要從備份還原嗎?',
|
||||
confirmMessage: '將從此提交還原所選類別。缺少的項目會被新增,既有項目保持不變。',
|
||||
confirmMessageOverwrite: '將從此提交還原所選類別,並覆寫本機已存在的項目。此操作無法復原。',
|
||||
kprofilesOverwriteCaveat: 'K 值設定檔是例外:寫入插槽一定會取代印表機上的校準資料。',
|
||||
tally: '已還原 {{restored}} 筆、略過 {{skipped}} 筆、失敗 {{failed}} 筆',
|
||||
reloadHint: '請重新載入 Bambuddy,讓還原的資料在各處生效。',
|
||||
partialHint: '上方列出的類別已完成並已儲存。未列出的類別沒有執行。',
|
||||
failed: '還原失敗。',
|
||||
loadFailed: '無法讀取備份儲存庫。',
|
||||
details: {
|
||||
notPresent: '此備份提交中不存在',
|
||||
unreadableJson: '無法解析的 JSON:{{paths}}',
|
||||
settingsNoPayload: '資料中沒有設定',
|
||||
settingsCredentialsWillSkip: '將略過 {{count}} 個疑似憑證的鍵',
|
||||
settingsCompanionWillSkip: '將略過 {{count}} 個疑似憑證的鍵,依賴它們的 {{companion}} 個開關會維持關閉',
|
||||
settingsCompanionOnlyWillSkip: '{{companion}} 個開關會維持關閉 - 每個開關所需的憑證無法從備份還原',
|
||||
spoolsUsageCount: '其中含 {{count}} 筆使用紀錄',
|
||||
archivesMetadataOnly: '僅中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
|
||||
kprofilesPrinterCount: '涵蓋 {{count}} 台印表機',
|
||||
},
|
||||
notes: {
|
||||
noData: '此備份中沒有這類資料',
|
||||
archivesPrinterMissing: '部分封存參照了已不存在的印表機 - 已清除連結',
|
||||
archivesProjectMissing: '部分封存參照了已不存在的專案 - 已清除連結',
|
||||
archivesOwnerCleared: '部分封存參照了已不存在的使用者 - 已清除擁有者,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到',
|
||||
archivesOwnerUnmatched: '部分封存指向本執行個體沒有的使用者 - 已清除擁有者,而非依備份中的使用者 ID 推測,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到',
|
||||
archivesOwnerUnknown: '部分封存還原時沒有擁有者 - 此備份未記錄擁有者,因此在管理員指派之前,只有具備 archives:read_all 權限的使用者才看得到',
|
||||
archivesUndeleted: '備份之後刪除的封存重新可見 - 當時啟用了覆寫',
|
||||
archivesMetadataOnly: '還原的封存僅含中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
|
||||
spoolUsageUnresolved: '已略過 {{count}} 筆使用紀錄 - 其耗材捲不在此備份的耗材清單中,沒有可掛接的對象。',
|
||||
spoolUsageUnlinked: '已還原 {{count}} 筆使用紀錄,但缺少列印歷史連結 - 請同時選擇「列印封存」與「耗材庫存」以保留該連結。',
|
||||
spoolTagKept: '{{count}} 個耗材標籤維持原樣 - 依備份內容會清除此後掃描過的標籤,或把它移到另一捲耗材上。',
|
||||
settingsCredentialsSkipped: '已略過 {{count}} 個疑似憑證的鍵 - 請手動重新輸入密鑰',
|
||||
settingsAuthSkipped: '已略過 {{count}} 項驗證設定 - 請在「設定 > 驗證」中修改,讓鎖定檢查繼續生效',
|
||||
settingsCompanionSkipped: '{{keys}} 維持關閉 - 它們各自所需的憑證無法從備份還原,本執行個體也沒有儲存,開啟會讓整合處於未驗證狀態',
|
||||
settingsMqttRelayFailed: 'MQTT 設定已還原,但中繼無法重新連線 - 請重新啟動 Bambuddy',
|
||||
kprofilesAlwaysOverwrite: 'K 值設定檔一律覆寫印表機上對應的插槽',
|
||||
kprofilesAckUnreliable: '印表機未回應時仍計為已還原 - 請在印表機上核對設定檔',
|
||||
kprofilesPrinterMissing: '沒有序號為 {{serial}} 的印表機 - 已略過',
|
||||
kprofilesPrinterOffline: '{{printer}}({{serial}})未連線 - 已略過',
|
||||
kprofilesUnknownNozzle: '{{serial}} 的噴嘴直徑 {{nozzle}} 不在預期範圍內 - 已原樣傳送',
|
||||
kprofilesUnmatched: '{{nozzle}} 的 {{count}} 個設定檔在 {{printer}} 上沒有對應項 - 已新增為新設定檔',
|
||||
kprofilesSendFailed: '無法將 {{nozzle}} 的設定檔傳送到 {{printer}}({{serial}})',
|
||||
kprofilesRefused: '{{printer}}({{serial}})拒絕了 {{nozzle}} 的設定檔:{{reason}}',
|
||||
kprofilesStepFailed: 'K 值設定檔步驟未能完成 - {{reason}}。在此之前還原的內容仍已儲存。',
|
||||
},
|
||||
},
|
||||
|
||||
// History
|
||||
history: '歷史紀錄',
|
||||
clear: '清除',
|
||||
date: '日期',
|
||||
status: '狀態',
|
||||
trigger: '類型',
|
||||
triggers: {
|
||||
manual: '備份(手動)',
|
||||
scheduled: '備份(排程)',
|
||||
restore: '還原',
|
||||
},
|
||||
commit: '提交',
|
||||
|
||||
// Local Backup
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { compareFwVersions } from '../utils/firmwareVersion';
|
||||
import { formatPrintName } from '../utils/printName';
|
||||
import { computePopoverPosition } from '../utils/popoverPosition';
|
||||
import { resolveDryingPresetKey, type DryingPreset } from '../utils/dryingPresets';
|
||||
import {
|
||||
isExternalSpoolHidden,
|
||||
setExternalSpoolHidden as persistExternalSpoolHidden,
|
||||
@@ -1781,7 +1782,7 @@ export function AmsNameHoverCard({
|
||||
|
||||
// AMS drying presets from BambuStudio filament profiles (idle mode temps)
|
||||
// Format: { n3f temp, n3s temp, n3f hours, n3s hours }
|
||||
const DRYING_PRESETS: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }> = {
|
||||
const DRYING_PRESETS: Record<string, DryingPreset> = {
|
||||
'PLA': { n3f: 45, n3s: 45, n3f_hours: 12, n3s_hours: 12 },
|
||||
'PETG': { n3f: 65, n3s: 65, n3f_hours: 12, n3s_hours: 12 },
|
||||
'TPU': { n3f: 65, n3s: 75, n3f_hours: 12, n3s_hours: 18 },
|
||||
@@ -1882,7 +1883,7 @@ function PrinterCard({
|
||||
cameraViewMode?: 'window' | 'embedded';
|
||||
onOpenEmbeddedCamera?: (printerId: number, printerName: string) => void;
|
||||
checkPrinterFirmware?: boolean;
|
||||
dryingPresets?: Record<string, { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number }>;
|
||||
dryingPresets?: Record<string, DryingPreset>;
|
||||
requirePlateClear?: boolean;
|
||||
selectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
@@ -4879,8 +4880,9 @@ function PrinterCard({
|
||||
setDryingPopoverAmsId(null);
|
||||
} else {
|
||||
const firstTray = ams.tray.find(t => t.tray_type);
|
||||
const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase();
|
||||
const preset = dryingPresets[filType] || dryingPresets['PLA'];
|
||||
const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
|
||||
// Only reachable if a custom preset set dropped PLA itself.
|
||||
const preset = dryingPresets[filType] ?? DRYING_PRESETS['PLA'];
|
||||
const moduleType = ams.module_type as 'n3f' | 'n3s';
|
||||
setDryingFilament(filType);
|
||||
setDryingTemp(preset[moduleType] || preset.n3f);
|
||||
@@ -5429,8 +5431,9 @@ function PrinterCard({
|
||||
setDryingPopoverAmsId(null);
|
||||
} else {
|
||||
const firstTray = ams.tray.find(t => t.tray_type);
|
||||
const filType = (firstTray?.tray_type || 'PLA').split(' ')[0].toUpperCase();
|
||||
const preset = dryingPresets[filType] || dryingPresets['PLA'];
|
||||
const filType = resolveDryingPresetKey(firstTray?.tray_type, dryingPresets);
|
||||
// Only reachable if a custom preset set dropped PLA itself.
|
||||
const preset = dryingPresets[filType] ?? DRYING_PRESETS['PLA'];
|
||||
const moduleType = ams.module_type as 'n3f' | 'n3s';
|
||||
setDryingFilament(filType);
|
||||
setDryingTemp(preset[moduleType] || preset.n3f);
|
||||
|
||||
@@ -682,6 +682,80 @@ export function ProjectDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* #1264: the whole programme in one place. Deliberately a separate card
|
||||
from the stats grid above — the figures there are this project's own
|
||||
prints, and the two must not read as one set. The API sends
|
||||
rollup_stats only when there is a sub-project, so there is never an
|
||||
identical pair on screen. */}
|
||||
{project.rollup_stats && (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<h2 className="text-lg font-semibold text-white flex items-center gap-2 mb-3">
|
||||
<FolderTree className="w-5 h-5" />
|
||||
{t('projectDetail.rollup.title', { count: project.descendant_count })}
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.printJobs')}</p>
|
||||
<p className="text-lg font-semibold text-white">{project.rollup_stats.total_archives}</p>
|
||||
<p className="text-sm text-bambu-gray">
|
||||
{t('projectDetail.stats.partsPrinted', { count: project.rollup_stats.completed_prints })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.printTime')}</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{formatDurationFromHours(project.rollup_stats.total_print_time_hours)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.stats.filamentUsed')}</p>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
{formatFilament(project.rollup_stats.total_filament_grams)}
|
||||
</p>
|
||||
</div>
|
||||
{(() => {
|
||||
const rollupCost =
|
||||
project.rollup_stats.estimated_cost +
|
||||
project.rollup_stats.total_energy_cost +
|
||||
project.rollup_stats.bom_cost;
|
||||
if (rollupCost <= 0) return null;
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-bambu-gray uppercase">{t('projectDetail.cost.totalCost')}</p>
|
||||
<p className="text-lg font-semibold text-bambu-green">
|
||||
{currency}{rollupCost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{project.rollup_stats.progress_percent !== null && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-bambu-gray">{t('projectDetail.rollup.progress')}</span>
|
||||
<span className="text-sm font-medium text-white">
|
||||
{t('projectDetail.rollup.percentComplete', {
|
||||
percent: project.rollup_stats.progress_percent.toFixed(0),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 bg-bambu-dark rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(project.rollup_stats.progress_percent, 100)}%`,
|
||||
backgroundColor:
|
||||
project.rollup_stats.progress_percent >= 100 ? '#22c55e' : project.color || '#6b7280',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cost tracking */}
|
||||
{stats && (() => {
|
||||
const totalCost = stats.estimated_cost + stats.total_energy_cost + stats.bom_cost;
|
||||
@@ -760,27 +834,48 @@ export function ProjectDetailPage() {
|
||||
<Link
|
||||
key={child.id}
|
||||
to={`/projects/${child.id}`}
|
||||
className="flex items-center justify-between p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
|
||||
className="flex items-center justify-between gap-4 p-3 bg-bambu-dark rounded-lg hover:bg-bambu-dark-tertiary transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: child.color || '#6b7280' }}
|
||||
/>
|
||||
<span className="text-white">{child.name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
<span className="text-white truncate">{child.name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded flex-shrink-0 ${
|
||||
child.status === 'completed' ? 'bg-status-ok/20 text-status-ok' :
|
||||
child.status === 'archived' ? 'bg-bambu-gray/20 text-bambu-gray' :
|
||||
'bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400'
|
||||
}`}>
|
||||
{child.status}
|
||||
</span>
|
||||
{/* A branch of its own, so its figures cover more than the
|
||||
one project named on this row (#1264). */}
|
||||
{child.descendant_count > 0 && (
|
||||
<span className="text-xs text-bambu-gray flex-shrink-0 inline-flex items-center gap-1">
|
||||
<FolderTree className="w-3 h-3" />
|
||||
{child.descendant_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Each row carries its own branch's roll-up, so the rows add
|
||||
up to the card above minus this project's own prints. */}
|
||||
<div className="flex items-center gap-4 text-sm text-bambu-gray flex-shrink-0">
|
||||
{child.total_archives > 0 && (
|
||||
<span className="hidden sm:inline">
|
||||
{t('projectDetail.subProjects.jobs', { count: child.total_archives })}
|
||||
</span>
|
||||
)}
|
||||
{child.total_filament_grams > 0 && (
|
||||
<span className="hidden md:inline">{formatFilament(child.total_filament_grams)}</span>
|
||||
)}
|
||||
{child.total_cost > 0 && (
|
||||
<span className="hidden md:inline">{currency}{child.total_cost.toFixed(2)}</span>
|
||||
)}
|
||||
{child.progress_percent !== null && (
|
||||
<span>{child.progress_percent.toFixed(0)}%</span>
|
||||
)}
|
||||
</div>
|
||||
{child.progress_percent !== null && (
|
||||
<span className="text-sm text-bambu-gray">
|
||||
{child.progress_percent.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -1474,6 +1569,10 @@ export function ProjectDetailPage() {
|
||||
failed_count: stats?.failed_prints || 0,
|
||||
queue_count: stats?.queued_prints || 0,
|
||||
progress_percent: stats?.progress_percent || null,
|
||||
// Required by ProjectListItem, but nothing in the dialog reads it —
|
||||
// the parent picker works off the project list it fetches itself.
|
||||
// Guarded like every other read of `children` on this page (#1264).
|
||||
child_count: project.children?.length ?? 0,
|
||||
archives: [],
|
||||
}}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ListTodo,
|
||||
Package,
|
||||
Layers,
|
||||
FolderTree,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
@@ -31,6 +32,7 @@ import { ConfirmModal } from '../components/ConfirmModal';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { getCurrencySymbol } from '../utils/currency';
|
||||
import { eligibleParents } from '../utils/projectTree';
|
||||
|
||||
const PROJECT_COLORS = [
|
||||
'#ef4444', // red
|
||||
@@ -69,7 +71,17 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
|
||||
const [budget, setBudget] = useState(project?.budget?.toString() || '');
|
||||
const [url, setUrl] = useState(project?.url || '');
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
const [parentId, setParentId] = useState<number | null>(project?.parent_id ?? null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Unfiltered on purpose: a completed or archived project is still a legal
|
||||
// parent, and the picker offering fewer options than the list does would be
|
||||
// hard to explain.
|
||||
const { data: allProjects } = useQuery({
|
||||
queryKey: ['projects', undefined],
|
||||
queryFn: () => api.getProjects(),
|
||||
});
|
||||
const parentOptions = eligibleParents(allProjects || [], project?.id);
|
||||
const [coverImageFilename, setCoverImageFilename] = useState(project?.cover_image_filename || null);
|
||||
const coverFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [coverUploading, setCoverUploading] = useState(false);
|
||||
@@ -132,6 +144,9 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
|
||||
// Pydantic accepts null to clear the URL; an empty string would fail the
|
||||
// http(s) prefix validator.
|
||||
url: project ? (trimmedUrl || null) : (trimmedUrl || undefined),
|
||||
// The API reads 0 as "remove the parent" — null would be indistinguishable
|
||||
// from the field having been omitted (#1264).
|
||||
parent_id: project ? (parentId ?? 0) : (parentId ?? undefined),
|
||||
...(project && { status }),
|
||||
});
|
||||
};
|
||||
@@ -195,6 +210,26 @@ export function ProjectModal({ project, onClose, onSave, isLoading, currencySymb
|
||||
{urlError && <p className="text-xs text-red-600 dark:text-red-400 mt-1">{urlError}</p>}
|
||||
</div>
|
||||
|
||||
{/* #1264: Nest this project under another one */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-white mb-1">
|
||||
{t('projects.parentLabel')}
|
||||
</label>
|
||||
<select
|
||||
value={parentId ?? ''}
|
||||
onChange={(e) => setParentId(e.target.value ? parseInt(e.target.value, 10) : null)}
|
||||
className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded px-3 py-2 text-white focus:outline-none focus:border-bambu-green"
|
||||
>
|
||||
<option value="">{t('projects.parentNone')}</option>
|
||||
{parentOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-bambu-gray mt-1">{t('projects.parentHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* #1155: Cover image — only available when editing an existing project,
|
||||
since uploading needs a project_id. New projects can add it after save. */}
|
||||
{project && (
|
||||
@@ -510,6 +545,7 @@ function ProjectCoverThumbnail({
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: ProjectListItem;
|
||||
parentName?: string; // #1264 — resolved by the caller, which holds the whole list
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
@@ -517,7 +553,7 @@ interface ProjectCardProps {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
|
||||
function ProjectCard({ project, parentName, onClick, onEdit, onDelete, hasPermission, t }: ProjectCardProps) {
|
||||
// Plates progress: archive_count / target_count
|
||||
const platesProgressPercent = project.target_count
|
||||
? Math.round((project.archive_count / project.target_count) * 100)
|
||||
@@ -621,7 +657,24 @@ function ProjectCard({ project, onClick, onEdit, onDelete, hasPermission, t }: P
|
||||
{t('projects.statusArchived')}
|
||||
</span>
|
||||
)}
|
||||
{/* #1264: without these, eight sub-projects of one programme
|
||||
look like eight unrelated projects in the grid. */}
|
||||
{project.child_count > 0 && (
|
||||
<span
|
||||
className="text-xs bg-bambu-dark text-bambu-gray px-2 py-0.5 rounded-full whitespace-nowrap inline-flex items-center gap-1"
|
||||
title={t('projects.subProjectCount', { count: project.child_count })}
|
||||
>
|
||||
<FolderTree className="w-3 h-3" />
|
||||
{project.child_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{parentName && (
|
||||
<p className="text-xs text-bambu-gray mt-1 flex items-center gap-1">
|
||||
<Layers className="w-3 h-3 flex-shrink-0" />
|
||||
<span className="truncate">{t('projects.partOf', { name: parentName })}</span>
|
||||
</p>
|
||||
)}
|
||||
{project.description && (
|
||||
<p className="text-sm text-bambu-gray/70 mt-1 line-clamp-1">
|
||||
{project.description}
|
||||
@@ -890,6 +943,96 @@ export function ProjectsPage() {
|
||||
queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
|
||||
});
|
||||
|
||||
// Parent names come from the unfiltered list, so a sub-project still says
|
||||
// what it belongs to when the status filter has hidden its parent (#1264).
|
||||
// Same query key the parent picker uses — one request shared between them,
|
||||
// and the same one as above whenever the filter is 'all'.
|
||||
const { data: allProjects } = useQuery({
|
||||
queryKey: ['projects', undefined],
|
||||
queryFn: () => api.getProjects(),
|
||||
});
|
||||
const namesById = new Map((allProjects || []).map((p) => [p.id, p.name]));
|
||||
|
||||
// A sub-project is drawn inside its parent's group rather than as another
|
||||
// card somewhere in the grid — two cards that belong together are not
|
||||
// something a caption can convey when they sit columns apart (#1264).
|
||||
const visible = projects || [];
|
||||
const visibleIds = new Set(visible.map((p) => p.id));
|
||||
const childrenByParent = new Map<number, ProjectListItem[]>();
|
||||
for (const project of visible) {
|
||||
// Parent hidden by the status filter: the child has nothing to nest under
|
||||
// here, so it stays a top-level card and keeps its "part of" caption.
|
||||
if (project.parent_id !== null && visibleIds.has(project.parent_id)) {
|
||||
const siblings = childrenByParent.get(project.parent_id) || [];
|
||||
siblings.push(project);
|
||||
childrenByParent.set(project.parent_id, siblings);
|
||||
}
|
||||
}
|
||||
// Everything drawn at the top of the grid, in order. Roots first, then any
|
||||
// project the roots cannot reach: a database written before the API refused
|
||||
// A -> B -> A can still hold a cycle, and a cycle has no root, so filtering
|
||||
// on "has no visible parent" alone would drop every project in it off the
|
||||
// page. Each entry point marks its whole branch as drawn, so nothing appears
|
||||
// twice either.
|
||||
const drawn = new Set<number>();
|
||||
const markDrawn = (id: number) => {
|
||||
if (drawn.has(id)) return;
|
||||
drawn.add(id);
|
||||
for (const child of childrenByParent.get(id) || []) markDrawn(child.id);
|
||||
};
|
||||
const topLevel: ProjectListItem[] = [];
|
||||
for (const project of visible) {
|
||||
if (project.parent_id !== null && visibleIds.has(project.parent_id)) continue;
|
||||
topLevel.push(project);
|
||||
markDrawn(project.id);
|
||||
}
|
||||
for (const project of visible) {
|
||||
if (drawn.has(project.id)) continue;
|
||||
topLevel.push(project);
|
||||
markDrawn(project.id);
|
||||
}
|
||||
|
||||
const renderProjectTree = (project: ProjectListItem, depth: number, seen: Set<number>) => {
|
||||
const card = (
|
||||
<ProjectCard
|
||||
project={project}
|
||||
// Only when it is detached from its parent — inside the group the
|
||||
// nesting is already visible, and the caption would be noise.
|
||||
parentName={depth === 0 && project.parent_id !== null ? namesById.get(project.parent_id) : undefined}
|
||||
onClick={() => handleClick(project)}
|
||||
onEdit={() => handleEdit(project)}
|
||||
onDelete={() => handleDeleteClick(project.id)}
|
||||
hasPermission={hasPermission}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
|
||||
// Drop any child that is already an ancestor on this path. A database
|
||||
// written before the API refused A -> B -> A can still hold a cycle, and
|
||||
// following one would draw the same project over and over.
|
||||
const descended = new Set(seen).add(project.id);
|
||||
const children = (childrenByParent.get(project.id) || []).filter((c) => !descended.has(c.id));
|
||||
if (children.length === 0) return <div key={project.id}>{card}</div>;
|
||||
|
||||
return (
|
||||
<div key={project.id} className="col-span-full space-y-4">
|
||||
{card}
|
||||
<div
|
||||
className="ml-4 md:ml-8 pl-4 md:pl-6 border-l-2 rounded-l space-y-4"
|
||||
style={{ borderColor: project.color || '#6b7280' }}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-wide text-bambu-gray flex items-center gap-1.5">
|
||||
<FolderTree className="w-3.5 h-3.5" />
|
||||
{t('projects.subProjectsOf', { name: project.name })}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{children.map((child) => renderProjectTree(child, depth + 1, descended))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: ProjectCreate) => api.createProject(data),
|
||||
onSuccess: () => {
|
||||
@@ -1159,17 +1302,7 @@ export function ProjectsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
{projects?.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
onClick={() => handleClick(project)}
|
||||
onEdit={() => handleEdit(project)}
|
||||
onDelete={() => handleDeleteClick(project.id)}
|
||||
hasPermission={hasPermission}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{topLevel.map((project) => renderProjectTree(project, 0, new Set()))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Resolving an AMS tray's material to a row in the drying preset table.
|
||||
//
|
||||
// The table itself lives with the UI that renders it; only the lookup is here,
|
||||
// so it can be exercised without dragging a page module into the test.
|
||||
|
||||
export type DryingPreset = { n3f: number; n3s: number; n3f_hours: number; n3s_hours: number };
|
||||
|
||||
// Materials whose AMS spelling differs from the preset table's key. Bambu
|
||||
// labels nylon "PA" while its own composites spell the family out, so PA6 and
|
||||
// PAHT would otherwise miss a table that has a perfectly good PA row.
|
||||
const DRYING_MATERIAL_ALIASES: Record<string, string> = {
|
||||
'NYLON': 'PA',
|
||||
'PA6': 'PA',
|
||||
'PAHT': 'PA',
|
||||
};
|
||||
|
||||
/**
|
||||
* Pick the preset key for a tray's material.
|
||||
*
|
||||
* The answer is always a key the table actually has, which is the whole point:
|
||||
* the drying popover seeds both the temperature and the filament name the start
|
||||
* command carries from this, and the dropdown silently falls back to its first
|
||||
* option when handed a value that isn't in its list. Seeding it with a raw
|
||||
* `tray_type` therefore displayed "PLA" while sending the raw string -- an
|
||||
* AMS-HT holding Support for PLA/PETG (`tray_type` "PLA-S") showed PLA in the
|
||||
* dropdown and told the printer PLA-S (#2774).
|
||||
*
|
||||
* `tray_type` carries plenty of spellings the table doesn't list: support
|
||||
* materials (PLA-S) and composites (PETG-CF, PLA-CF, ABS-GF, PAHT-CF) all dry
|
||||
* as their base material, so the suffix is dropped before giving up. Anything
|
||||
* still unrecognised lands on PLA, deliberately the coolest row -- under-drying
|
||||
* an exotic filament wastes a cycle, where defaulting to PA's 85 degrees would
|
||||
* deform a PLA spool.
|
||||
*/
|
||||
export function resolveDryingPresetKey(
|
||||
trayType: string | null | undefined,
|
||||
presets: Record<string, DryingPreset>,
|
||||
): string {
|
||||
const raw = (trayType || '').split(' ')[0].toUpperCase();
|
||||
for (const candidate of [raw, raw.split('-')[0]]) {
|
||||
const key = DRYING_MATERIAL_ALIASES[candidate] ?? candidate;
|
||||
if (presets[key]) return key;
|
||||
}
|
||||
return 'PLA';
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ProjectListItem } from '../api/client';
|
||||
|
||||
/**
|
||||
* Projects that may legally become `projectId`'s parent (#1264).
|
||||
*
|
||||
* Its own descendants are excluded as well as itself: nesting a project under
|
||||
* something already beneath it makes a cycle, which the API rejects anyway, so
|
||||
* offering it would only produce an error the user cannot act on. Walked from
|
||||
* the flat list rather than fetched, since every row carries its `parent_id`.
|
||||
*/
|
||||
export function eligibleParents(
|
||||
projects: ProjectListItem[],
|
||||
projectId: number | undefined,
|
||||
): ProjectListItem[] {
|
||||
if (projectId === undefined) return projects;
|
||||
const blocked = new Set([projectId]);
|
||||
// Repeat until nothing new is blocked: the list is in no particular order, so
|
||||
// a grandchild can appear before its parent has been blocked.
|
||||
let grew = true;
|
||||
while (grew) {
|
||||
grew = false;
|
||||
for (const candidate of projects) {
|
||||
if (candidate.parent_id !== null && blocked.has(candidate.parent_id) && !blocked.has(candidate.id)) {
|
||||
blocked.add(candidate.id);
|
||||
grew = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return projects.filter((p) => !blocked.has(p.id));
|
||||
}
|
||||
Reference in New Issue
Block a user