Merge branch 'dev' into feature/billing

This commit is contained in:
behrinml
2026-08-06 20:32:59 +02:00
56 changed files with 12670 additions and 363 deletions
@@ -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');
});
});
+110
View File
@@ -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)}
/>
)}
</>
);
}
+85 -1
View File
@@ -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
+90 -1
View File
@@ -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
+85 -1
View File
@@ -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
+85 -1
View File
@@ -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
+85 -1
View File
@@ -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
+85 -1
View File
@@ -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
+86 -2
View File
@@ -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 데이터의 전체 백업을 만듭니다.',
+85 -1
View File
@@ -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
+85 -1
View File
@@ -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, включая базу данных, архивы, загрузки и все файлы.",
+85 -1
View File
@@ -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
+85 -1
View File
@@ -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
+85 -1
View File
@@ -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
+85 -1
View File
@@ -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
+9 -6
View File
@@ -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);
+109 -10
View File
@@ -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)}
+145 -12
View File
@@ -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>
)}
+45
View File
@@ -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';
}
+30
View File
@@ -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));
}