Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ popover, not for the person who wrote the commit.

## [Unreleased]

### Fixed
- **Importing a context whose rules match your current ones no longer reports a conflict.**
The check compared the imported and current rules as JSON text, and an export writes a
rule's fields in a different order than the app does - so any context with auto-switch
rules was flagged as conflicting with itself on every import, asking you to pick between
two copies of the same rule
- **Export, import and confirmation dialogs no longer touch the window edges on a narrow
window.** Each had a maximum width but no side margin, so below about 670px wide the panel
ran edge to edge with no gap on either side

## [1.8.10] - 2026-09-27

### Changed
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/ConfirmationDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@
<div
use:portal={"body"}
use:trapFocus
class="fixed left-[50%] top-[50%] z-[100] w-full max-w-sm translate-x-[-50%] translate-y-[-50%] outline-none"
class="fixed left-[50%] top-[50%] z-[100] w-full max-w-sm translate-x-[-50%] translate-y-[-50%] outline-none px-4"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/ExportDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@
<div
use:portal={"body"}
use:trapFocus
class="fixed left-[50%] top-[50%] z-[100] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] outline-none max-h-[85vh] flex flex-col"
class="fixed left-[50%] top-[50%] z-[100] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] outline-none px-4 max-h-[85vh] flex flex-col"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
Expand Down
14 changes: 5 additions & 9 deletions src/lib/components/ImportDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import { DesktopStorageAdapter } from "$lib/services/desktop-adapter";
import { getRelativeTime } from "$lib/utils/date";
import { errorText } from "$lib/errors";
import { sameRules } from "$lib/utils/rules";
import {
Upload,
FileText,
Expand Down Expand Up @@ -300,13 +301,8 @@
hasConflict = true;
}

// Check rules
// For now, simple JSON stringify comparison.
// Ideally we should sort rules but order might matter.
// Let's assume order matters.
const currentRules = JSON.stringify(context.rules || []);
const importedRules = JSON.stringify(metadata.rules || []);
if (importedRules !== currentRules) {
// Check rules, by what they do rather than by their JSON text.
if (!sameRules(context.rules, metadata.rules)) {
conflicts.rules = true;
hasConflict = true;
}
Expand Down Expand Up @@ -472,7 +468,7 @@
<div
use:portal={"body"}
use:trapFocus
class="fixed left-[50%] top-[50%] z-[100] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] outline-none max-h-[85vh] flex flex-col"
class="fixed left-[50%] top-[50%] z-[100] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] outline-none px-4 max-h-[85vh] flex flex-col"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
Expand Down Expand Up @@ -810,7 +806,7 @@
<div
use:portal={"body"}
use:trapFocus
class="fixed left-[50%] top-[50%] z-[200] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] outline-none max-h-[85vh] flex flex-col"
class="fixed left-[50%] top-[50%] z-[200] w-full max-w-2xl translate-x-[-50%] translate-y-[-50%] outline-none px-4 max-h-[85vh] flex flex-col"
role="dialog"
aria-modal="true"
aria-labelledby={conflictTitleId}
Expand Down
54 changes: 54 additions & 0 deletions src/lib/utils/__tests__/rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: AGPL-3.0-only

// Copyright (C) 2026 Nico Wiedemann
//
// This file is part of Stashpad.
// Stashpad is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License, version 3,
// as published by the Free Software Foundation.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
// See the GNU Affero General Public License for more details.

import { describe, it, expect } from 'vitest';
import type { ContextRule } from '$lib/types';
import { sameRules } from '../rules';

const coil: ContextRule = {
ruleType: 'title',
value: '[coil]',
matchType: 'contains',
matchCase: false,
useRegex: false,
};

describe('sameRules', () => {
it('ignores the order of a rule\'s fields', () => {
// What an export hands back: the same rule, keys sorted.
const exported = JSON.parse(
'{"matchCase":false,"matchType":"contains","ruleType":"title","useRegex":false,"value":"[coil]"}',
) as ContextRule;
expect(sameRules([coil], [exported])).toBe(true);
});

it('treats a missing flag as false', () => {
const bare: ContextRule = { ruleType: 'title', value: '[coil]', matchType: 'contains' };
expect(sameRules([coil], [bare])).toBe(true);
});

it('treats a missing list as empty', () => {
expect(sameRules(undefined, [])).toBe(true);
expect(sameRules(undefined, [coil])).toBe(false);
});

it('still sees a real difference', () => {
expect(sameRules([coil], [{ ...coil, value: '[other]' }])).toBe(false);
expect(sameRules([coil], [{ ...coil, matchCase: true }])).toBe(false);
});

it('keeps the order of the rules significant', () => {
const other: ContextRule = { ...coil, ruleType: 'process', value: 'Code.exe' };
expect(sameRules([coil, other], [other, coil])).toBe(false);
});
});
45 changes: 45 additions & 0 deletions src/lib/utils/rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: AGPL-3.0-only

// Copyright (C) 2026 Nico Wiedemann
//
// This file is part of Stashpad.
// Stashpad is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License, version 3,
// as published by the Free Software Foundation.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
// See the GNU Affero General Public License for more details.

import type { ContextRule } from "$lib/types";

/** One rule reduced to what it means, in a fixed shape. */
function canonical(rule: ContextRule): string {
return JSON.stringify([
rule.ruleType,
rule.matchType,
rule.value,
rule.matchCase ?? false,
rule.useRegex ?? false,
]);
}

/**
* Whether two rule lists do the same thing.
*
* Comparing their JSON text, which the import dialog used to do, counted the order of each
* rule's fields as a difference. An export writes them alphabetically - the Rust side goes
* through `serde_json::Value`, whose map is sorted - while the app keeps them in the order
* it builds them, so every context with rules came back as a conflict with itself. A
* missing `matchCase` or `useRegex` is also the same as `false`, which is what both sides
* treat it as. The order of the rules themselves is kept significant: it is the order they
* are shown and edited in.
*/
export function sameRules(
a: ContextRule[] | undefined,
b: ContextRule[] | undefined,
): boolean {
const left = (a ?? []).map(canonical);
const right = (b ?? []).map(canonical);
return left.length === right.length && left.every((rule, i) => rule === right[i]);
}
Loading