From 749445862f8e59f9300e84e4cd5726bec61b52bd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 09:50:08 +0000
Subject: [PATCH 1/9] Initial plan
From b2a8362321da632eb28ade6851edf5355a566a1a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 09:54:43 +0000
Subject: [PATCH 2/9] Add Pattern model and update Profile to support Use With
Sites patterns
Co-authored-by: NoelLH <3274454+NoelLH@users.noreply.github.com>
---
src/app/import.service.ts | 48 ++++++++++++++++++------
src/app/profile/profile.page.html | 16 ++++++++
src/app/profile/profile.page.ts | 62 +++++++++++++++++++++++++++++++
src/models/Pattern.ts | 6 +++
src/models/Profile.ts | 3 ++
5 files changed, 124 insertions(+), 11 deletions(-)
create mode 100644 src/models/Pattern.ts
diff --git a/src/app/import.service.ts b/src/app/import.service.ts
index 571b3d4..537ff07 100644
--- a/src/app/import.service.ts
+++ b/src/app/import.service.ts
@@ -2,6 +2,7 @@ import { Injectable, inject } from '@angular/core';
import { Platform } from '@ionic/angular/standalone';
import { Filesystem, Directory, Encoding } from '@capacitor/filesystem';
import { Profile } from '../models/Profile';
+import { Pattern } from '../models/Pattern';
export interface ImportedProfile {
title?: string;
@@ -18,6 +19,7 @@ export interface ImportedProfile {
url_domain?: boolean;
url_path?: boolean;
siteList?: string;
+ patterns?: Pattern[];
rdf_about?: string;
description?: string;
}
@@ -166,7 +168,7 @@ export class ImportService {
}
// Parse site patterns
- prof.siteList = this.parseSitePatterns(item);
+ prof.patterns = this.parseSitePatterns(item);
// Categorize the profile
if (prof.rdf_about === 'http://passwordmaker.mozdev.org/globalSettings') {
@@ -254,6 +256,11 @@ export class ImportService {
// Handle domain_only mapping from URL components
profile.domain_only = !importedProfile.url_path && !importedProfile.url_subdomain && !!importedProfile.url_domain;
+ // Map patterns
+ if (importedProfile.patterns && importedProfile.patterns.length > 0) {
+ profile.patterns = importedProfile.patterns;
+ }
+
return profile;
}
@@ -324,6 +331,19 @@ export class ImportService {
profiles.forEach((profile, index) => {
const about = index === 0 ? 'http://passwordmaker.mozdev.org/defaults' : `rdf:#$CHROME${index}`;
+ // Build pattern attributes
+ let patternAttrs = '';
+ if (profile.patterns && profile.patterns.length > 0) {
+ profile.patterns.forEach((pattern, patternIndex) => {
+ if (pattern.enabled) {
+ patternAttrs += `\n NS1:pattern${patternIndex}="${this.escapeXml(pattern.pattern)}"`;
+ patternAttrs += `\n NS1:patternenabled${patternIndex}="true"`;
+ patternAttrs += `\n NS1:patterndesc${patternIndex}="${this.escapeXml(pattern.description || '')}"`;
+ patternAttrs += `\n NS1:patterntype${patternIndex}="${pattern.type}"`;
+ }
+ });
+ }
+
rdf += `
`;
+ NS1:pathCB="${!profile.domain_only}"${patternAttrs} />`;
});
rdf += `
@@ -357,14 +377,15 @@ export class ImportService {
return rdf;
}
- private parseSitePatterns(item: Element): string {
+ private parseSitePatterns(item: Element): Pattern[] {
const patterns: string[] = [];
const patternTypes: string[] = [];
const patternEnabled: string[] = [];
+ const patternDesc: string[] = [];
Array.from(item.attributes).forEach(attr => {
const attrName = attr.localName;
- const match = attrName.match(/pattern(|type|enabled)(\d+)/);
+ const match = attrName.match(/pattern(|type|enabled|desc)(\d+)/);
if (match) {
const index = parseInt(match[2]);
@@ -378,22 +399,27 @@ export class ImportService {
case 'enabled':
patternEnabled[index] = attr.value;
break;
+ case 'desc':
+ patternDesc[index] = attr.value;
+ break;
}
}
});
- const siteList: string[] = [];
+ const result: Pattern[] = [];
patterns.forEach((pattern, index) => {
+ // Only import enabled patterns as per requirements
if (patternEnabled[index] === 'true' && pattern) {
- if (patternTypes[index] === 'regex') {
- siteList.push(`/${pattern}/`);
- } else {
- siteList.push(pattern);
- }
+ result.push({
+ pattern,
+ enabled: true,
+ type: patternTypes[index] === 'regex' ? 'regex' : 'wildcard',
+ description: patternDesc[index] || ''
+ });
}
});
- return siteList.join(' ');
+ return result;
}
private strToBool(value: string): boolean {
diff --git a/src/app/profile/profile.page.html b/src/app/profile/profile.page.html
index 0bd078e..7d0c41c 100644
--- a/src/app/profile/profile.page.html
+++ b/src/app/profile/profile.page.html
@@ -162,6 +162,22 @@
enable-on-off-labels="true"
>Use just domain name?
+
+
+
+
+
+ URLs should be separated by spaces or line breaks. Use wildcards (e.g., *.example.com) or RegEx (e.g., /https?://my\.example\.com\/.*/) to match multiple sites.
+
0) {
+ this.patternsText = this.patternsToText(this.profileModel.patterns);
+ }
+
this.profile.patchValue(formValues);
this.lastCharacterSetPreset = this.profileModel.output_character_set_preset;
}
@@ -110,6 +117,10 @@ export class ProfilePageComponent implements OnInit {
}
value.profile_id = this.profileId;
+
+ // Convert patterns text to array before saving
+ value.patterns = this.textToPatterns(this.patternsText);
+
this.settingsService.saveProfile(value)
.then(
() => {
@@ -169,6 +180,57 @@ export class ProfilePageComponent implements OnInit {
this.modalController.dismiss();
}
+ /**
+ * Convert patterns array to text for editing in textarea
+ */
+ private patternsToText(patterns: Pattern[]): string {
+ return patterns
+ .filter(p => p.enabled)
+ .map(p => {
+ // Wrap regex patterns in slashes
+ if (p.type === 'regex') {
+ return `/${p.pattern}/`;
+ }
+ return p.pattern;
+ })
+ .join('\n');
+ }
+
+ /**
+ * Convert text from textarea to patterns array
+ */
+ private textToPatterns(text: string): Pattern[] {
+ if (!text || text.trim() === '') {
+ return [];
+ }
+
+ // Split by whitespace or line breaks
+ const lines = text.split(/[\s\n]+/).filter(line => line.trim() !== '');
+
+ return lines.map(line => {
+ const trimmed = line.trim();
+
+ // Check if it's a regex pattern (wrapped in slashes)
+ const regexMatch = trimmed.match(/^\/(.+)\/$/);
+ if (regexMatch) {
+ return {
+ pattern: regexMatch[1],
+ enabled: true,
+ type: 'regex' as const,
+ description: ''
+ };
+ }
+
+ // Otherwise it's a wildcard pattern
+ return {
+ pattern: trimmed,
+ enabled: true,
+ type: 'wildcard' as const,
+ description: ''
+ };
+ });
+ }
+
private requireIfNoPresetValidator(outputCharacterSetCustomControl: AbstractControl) {
if (!outputCharacterSetCustomControl.parent) {
return null;
diff --git a/src/models/Pattern.ts b/src/models/Pattern.ts
new file mode 100644
index 0000000..5a97b89
--- /dev/null
+++ b/src/models/Pattern.ts
@@ -0,0 +1,6 @@
+export interface Pattern {
+ pattern: string;
+ enabled: boolean;
+ type: 'wildcard' | 'regex';
+ description?: string;
+}
diff --git a/src/models/Profile.ts b/src/models/Profile.ts
index ba7dadc..f631239 100644
--- a/src/models/Profile.ts
+++ b/src/models/Profile.ts
@@ -1,3 +1,5 @@
+import { Pattern } from './Pattern';
+
export class Profile {
public profile_id: number;
public algorithm: 'hmac-sha256' | 'sha256' | 'hmac-sha1' | 'sha1' | 'hmac-md5' | 'md5' | 'hmac-ripemd160' | 'ripemd160' = 'hmac-sha256';
@@ -12,4 +14,5 @@ export class Profile {
public post_processing_suffix = '';
public prefix = '';
public suffix = '';
+ public patterns: Pattern[] = [];
}
From ba5bc2dba009d8d913c18c9c18d84911712942b8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 09:56:04 +0000
Subject: [PATCH 3/9] Add pattern matching service and auto-profile selection
on home page
Co-authored-by: NoelLH <3274454+NoelLH@users.noreply.github.com>
---
src/app/home/home.page.html | 2 +-
src/app/home/home.page.ts | 31 +++++++++
src/app/pattern-matcher.service.ts | 106 +++++++++++++++++++++++++++++
3 files changed, 138 insertions(+), 1 deletion(-)
create mode 100644 src/app/pattern-matcher.service.ts
diff --git a/src/app/home/home.page.html b/src/app/home/home.page.html
index d3b030a..c9fe612 100644
--- a/src/app/home/home.page.html
+++ b/src/app/home/home.page.html
@@ -14,7 +14,7 @@
[(ngModel)]="input.host"
inputmode="url"
type="text"
- (ionInput)="update()"
+ (ionInput)="onHostChange()"
(keyup.enter)="hideKeyboard()"
>
diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts
index d7fd6da..ad32f6a 100644
--- a/src/app/home/home.page.ts
+++ b/src/app/home/home.page.ts
@@ -7,6 +7,7 @@ import { informationCircleOutline, warning, copy } from 'ionicons/icons';
import { Input } from '../../models/Input';
import { PasswordsService } from '../passwords.service';
+import { PatternMatcherService } from '../pattern-matcher.service';
import { Settings } from '../../models/Settings';
import { SettingsAdvanced } from '../../models/SettingsAdvanced';
import { SettingsService } from '../settings.service';
@@ -21,6 +22,7 @@ export class HomePageComponent implements OnInit {
private changeDetector = inject(ChangeDetectorRef);
loadingController = inject(LoadingController);
private passwordsService = inject(PasswordsService);
+ private patternMatcher = inject(PatternMatcherService);
private platform = inject(Platform);
private settingsService = inject(SettingsService);
toast = inject(ToastController);
@@ -38,6 +40,7 @@ export class HomePageComponent implements OnInit {
private expiry_timer_id: number;
private loading: HTMLIonLoadingElement;
protected master_password_hash?: string;
+ private userChangedProfile = false; // Track if user manually changed profile
constructor() {
addIcons({ informationCircleOutline, warning, copy });
@@ -87,6 +90,11 @@ export class HomePageComponent implements OnInit {
if (settings instanceof SettingsAdvanced) {
this.advanced_mode = true;
this.input.active_profile_id = settings.active_profile_id;
+
+ // Auto-select profile based on patterns if user hasn't manually changed it
+ if (!this.userChangedProfile && this.input.host.length > 0) {
+ this.autoSelectProfileForHost(settings);
+ }
}
if (this.input.master_password.length === 0 || this.input.host.length === 0) {
@@ -132,11 +140,34 @@ export class HomePageComponent implements OnInit {
switchProfile(event: any) {
if (this.settings instanceof SettingsAdvanced) {
+ this.userChangedProfile = true; // Mark that user manually changed profile
this.settings.setActiveProfile(event.detail.value);
this.settingsService.save(this.settings);
}
}
+ /**
+ * Auto-select a profile based on URL pattern matching
+ */
+ private autoSelectProfileForHost(settings: SettingsAdvanced) {
+ const matchingProfile = this.patternMatcher.findMatchingProfile(this.input.host, settings.profiles);
+
+ if (matchingProfile && matchingProfile.profile_id !== this.input.active_profile_id) {
+ this.input.active_profile_id = matchingProfile.profile_id;
+ settings.setActiveProfile(matchingProfile.profile_id);
+ // Note: We don't save settings here to avoid constant writes as user types
+ // The profile will be used for this session but won't persist unless user manually selects it
+ }
+ }
+
+ /**
+ * Reset the userChangedProfile flag when host changes
+ */
+ onHostChange() {
+ this.userChangedProfile = false;
+ this.update();
+ }
+
copy() {
Clipboard.write({ string: this.output_password }).then(() => {
this.toast.create({
diff --git a/src/app/pattern-matcher.service.ts b/src/app/pattern-matcher.service.ts
new file mode 100644
index 0000000..0a9d8bc
--- /dev/null
+++ b/src/app/pattern-matcher.service.ts
@@ -0,0 +1,106 @@
+import { Injectable } from '@angular/core';
+import { Profile } from '../models/Profile';
+import { Pattern } from '../models/Pattern';
+
+/**
+ * Service for matching URLs/domains against profile patterns
+ */
+@Injectable({
+ providedIn: 'root'
+})
+export class PatternMatcherService {
+
+ /**
+ * Find the first profile that matches the given host/URL
+ * @param host The URL or domain to match
+ * @param profiles Array of profiles to search
+ * @returns The matching profile, or null if no match found
+ */
+ findMatchingProfile(host: string, profiles: Profile[]): Profile | null {
+ if (!host || !profiles || profiles.length === 0) {
+ return null;
+ }
+
+ // Iterate through profiles to find first match
+ for (const profile of profiles) {
+ if (this.profileMatchesHost(profile, host)) {
+ return profile;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if a profile's patterns match the given host
+ * @param profile The profile to check
+ * @param host The host/URL to match against
+ * @returns true if any pattern in the profile matches
+ */
+ profileMatchesHost(profile: Profile, host: string): boolean {
+ if (!profile.patterns || profile.patterns.length === 0) {
+ return false;
+ }
+
+ return profile.patterns.some(pattern =>
+ pattern.enabled && this.patternMatches(pattern, host)
+ );
+ }
+
+ /**
+ * Check if a single pattern matches the host
+ * @param pattern The pattern to check
+ * @param host The host/URL to match
+ * @returns true if the pattern matches
+ */
+ private patternMatches(pattern: Pattern, host: string): boolean {
+ if (!pattern || !pattern.pattern || !host) {
+ return false;
+ }
+
+ try {
+ if (pattern.type === 'regex') {
+ // For regex patterns, compile and test
+ const regex = new RegExp(pattern.pattern);
+ return regex.test(host);
+ } else {
+ // For wildcard patterns, convert to regex
+ // Escape special regex characters except *
+ const regexPattern = pattern.pattern
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
+ .replace(/\*/g, '.*');
+
+ const regex = new RegExp(`^${regexPattern}$`, 'i'); // Case-insensitive
+ return regex.test(host);
+ }
+ } catch (error) {
+ console.error('Error matching pattern:', pattern, error);
+ return false;
+ }
+ }
+
+ /**
+ * Extract domain from URL if it's a full URL
+ * @param input The input string (URL or domain)
+ * @returns The domain portion
+ */
+ extractDomain(input: string): string {
+ if (!input) {
+ return '';
+ }
+
+ try {
+ // If it starts with http:// or https://, parse as URL
+ if (input.match(/^https?:\/\//i)) {
+ const url = new URL(input);
+ return url.hostname;
+ }
+
+ // Otherwise assume it's already a domain
+ return input;
+ } catch (error) {
+ // If URL parsing fails, return the original input
+ return input;
+ }
+ }
+}
From 15ee9db2981c5721e073bb0b955d0971e7c40d84 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 09:57:07 +0000
Subject: [PATCH 4/9] Add unit tests for pattern matching and import/export
functionality
Co-authored-by: NoelLH <3274454+NoelLH@users.noreply.github.com>
---
src/app/import.service.spec.ts | 211 ++++++++++++++++++++++++
src/app/pattern-matcher.service.spec.ts | 168 +++++++++++++++++++
2 files changed, 379 insertions(+)
create mode 100644 src/app/import.service.spec.ts
create mode 100644 src/app/pattern-matcher.service.spec.ts
diff --git a/src/app/import.service.spec.ts b/src/app/import.service.spec.ts
new file mode 100644
index 0000000..16b37be
--- /dev/null
+++ b/src/app/import.service.spec.ts
@@ -0,0 +1,211 @@
+import { TestBed } from '@angular/core/testing';
+import { ImportService } from './import.service';
+import { Profile } from '../models/Profile';
+
+describe('ImportService', () => {
+ let service: ImportService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [ImportService]
+ });
+ service = TestBed.inject(ImportService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('parseSitePatterns', () => {
+ it('should parse wildcard patterns from RDF', () => {
+ const xmlString = `
+
+ `;
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(xmlString, 'text/xml');
+ const element = doc.getElementsByTagName('RDF:Description')[0];
+
+ const patterns = service['parseSitePatterns'](element);
+
+ expect(patterns.length).toBe(2);
+ expect(patterns[0]).toEqual({
+ pattern: 'domain1.com',
+ enabled: true,
+ type: 'wildcard',
+ description: ''
+ });
+ expect(patterns[1]).toEqual({
+ pattern: 'domain2.uk',
+ enabled: true,
+ type: 'wildcard',
+ description: ''
+ });
+ });
+
+ it('should parse regex patterns from RDF', () => {
+ const xmlString = `
+
+ `;
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(xmlString, 'text/xml');
+ const element = doc.getElementsByTagName('RDF:Description')[0];
+
+ const patterns = service['parseSitePatterns'](element);
+
+ expect(patterns.length).toBe(1);
+ expect(patterns[0].type).toBe('regex');
+ expect(patterns[0].pattern).toBe('https?://my\\.example\\.com/.*');
+ });
+
+ it('should skip disabled patterns', () => {
+ const xmlString = `
+
+ `;
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(xmlString, 'text/xml');
+ const element = doc.getElementsByTagName('RDF:Description')[0];
+
+ const patterns = service['parseSitePatterns'](element);
+
+ expect(patterns.length).toBe(1);
+ expect(patterns[0].pattern).toBe('enabled.com');
+ });
+ });
+
+ describe('generateRdfExport', () => {
+ it('should export patterns in RDF format', () => {
+ const profile = new Profile();
+ profile.name = 'Test Profile';
+ profile.patterns = [
+ {
+ pattern: 'example.com',
+ enabled: true,
+ type: 'wildcard',
+ description: 'Test pattern'
+ },
+ {
+ pattern: 'https?://.*\\.test\\.com/.*',
+ enabled: true,
+ type: 'regex',
+ description: ''
+ }
+ ];
+
+ const rdf = service.generateRdfExport([profile]);
+
+ expect(rdf).toContain('NS1:pattern0="example.com"');
+ expect(rdf).toContain('NS1:patternenabled0="true"');
+ expect(rdf).toContain('NS1:patterntype0="wildcard"');
+ expect(rdf).toContain('NS1:patterndesc0="Test pattern"');
+
+ expect(rdf).toContain('NS1:pattern1="https?://.*\\.test\\.com/.*"');
+ expect(rdf).toContain('NS1:patternenabled1="true"');
+ expect(rdf).toContain('NS1:patterntype1="regex"');
+ });
+
+ it('should not export disabled patterns', () => {
+ const profile = new Profile();
+ profile.name = 'Test Profile';
+ profile.patterns = [
+ {
+ pattern: 'disabled.com',
+ enabled: false,
+ type: 'wildcard',
+ description: ''
+ }
+ ];
+
+ const rdf = service.generateRdfExport([profile]);
+
+ expect(rdf).not.toContain('disabled.com');
+ });
+
+ it('should handle profiles with no patterns', () => {
+ const profile = new Profile();
+ profile.name = 'Test Profile';
+ profile.patterns = [];
+
+ const rdf = service.generateRdfExport([profile]);
+
+ expect(rdf).toContain('NS1:name="Test Profile"');
+ expect(rdf).not.toContain('NS1:pattern');
+ });
+
+ it('should escape XML special characters in patterns', () => {
+ const profile = new Profile();
+ profile.name = 'Test Profile';
+ profile.patterns = [
+ {
+ pattern: 'test<>&"\'pattern',
+ enabled: true,
+ type: 'wildcard',
+ description: 'desc<>&"\''
+ }
+ ];
+
+ const rdf = service.generateRdfExport([profile]);
+
+ expect(rdf).toContain('<');
+ expect(rdf).toContain('>');
+ expect(rdf).toContain('&');
+ expect(rdf).toContain('"');
+ expect(rdf).toContain(''');
+ });
+ });
+
+ describe('convertToProfile', () => {
+ it('should convert imported patterns to Profile', () => {
+ const imported = {
+ title: 'Test Profile',
+ patterns: [
+ {
+ pattern: 'example.com',
+ enabled: true,
+ type: 'wildcard' as const,
+ description: 'Test'
+ }
+ ]
+ };
+
+ const profile = service.convertToProfile(imported);
+
+ expect(profile.name).toBe('Test Profile');
+ expect(profile.patterns.length).toBe(1);
+ expect(profile.patterns[0].pattern).toBe('example.com');
+ });
+
+ it('should handle missing patterns', () => {
+ const imported = {
+ title: 'Test Profile'
+ };
+
+ const profile = service.convertToProfile(imported);
+
+ expect(profile.patterns).toEqual([]);
+ });
+ });
+});
diff --git a/src/app/pattern-matcher.service.spec.ts b/src/app/pattern-matcher.service.spec.ts
new file mode 100644
index 0000000..84dada3
--- /dev/null
+++ b/src/app/pattern-matcher.service.spec.ts
@@ -0,0 +1,168 @@
+import { TestBed } from '@angular/core/testing';
+import { PatternMatcherService } from './pattern-matcher.service';
+import { Profile } from '../models/Profile';
+import { Pattern } from '../models/Pattern';
+
+describe('PatternMatcherService', () => {
+ let service: PatternMatcherService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(PatternMatcherService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('extractDomain', () => {
+ it('should extract domain from http URL', () => {
+ expect(service.extractDomain('http://example.com')).toBe('example.com');
+ });
+
+ it('should extract domain from https URL', () => {
+ expect(service.extractDomain('https://www.example.com')).toBe('www.example.com');
+ });
+
+ it('should return input if not a URL', () => {
+ expect(service.extractDomain('example.com')).toBe('example.com');
+ });
+
+ it('should handle empty input', () => {
+ expect(service.extractDomain('')).toBe('');
+ });
+ });
+
+ describe('patternMatches', () => {
+ it('should match exact wildcard domain', () => {
+ const pattern: Pattern = {
+ pattern: 'example.com',
+ enabled: true,
+ type: 'wildcard'
+ };
+ expect(service['patternMatches'](pattern, 'example.com')).toBe(true);
+ });
+
+ it('should match wildcard pattern with asterisk', () => {
+ const pattern: Pattern = {
+ pattern: '*.example.com',
+ enabled: true,
+ type: 'wildcard'
+ };
+ expect(service['patternMatches'](pattern, 'www.example.com')).toBe(true);
+ expect(service['patternMatches'](pattern, 'api.example.com')).toBe(true);
+ expect(service['patternMatches'](pattern, 'example.com')).toBe(false);
+ });
+
+ it('should match regex pattern', () => {
+ const pattern: Pattern = {
+ pattern: 'https?://.*\\.example\\.com/.*',
+ enabled: true,
+ type: 'regex'
+ };
+ expect(service['patternMatches'](pattern, 'https://www.example.com/path')).toBe(true);
+ expect(service['patternMatches'](pattern, 'http://api.example.com/v1')).toBe(true);
+ expect(service['patternMatches'](pattern, 'example.com')).toBe(false);
+ });
+
+ it('should be case-insensitive for wildcard patterns', () => {
+ const pattern: Pattern = {
+ pattern: 'Example.Com',
+ enabled: true,
+ type: 'wildcard'
+ };
+ expect(service['patternMatches'](pattern, 'example.com')).toBe(true);
+ expect(service['patternMatches'](pattern, 'EXAMPLE.COM')).toBe(true);
+ });
+
+ it('should handle disabled patterns', () => {
+ const pattern: Pattern = {
+ pattern: 'example.com',
+ enabled: false,
+ type: 'wildcard'
+ };
+ // Note: patternMatches doesn't check enabled status, that's in profileMatchesHost
+ expect(service['patternMatches'](pattern, 'example.com')).toBe(true);
+ });
+
+ it('should handle invalid regex gracefully', () => {
+ const pattern: Pattern = {
+ pattern: '[invalid(regex',
+ enabled: true,
+ type: 'regex'
+ };
+ expect(service['patternMatches'](pattern, 'example.com')).toBe(false);
+ });
+ });
+
+ describe('profileMatchesHost', () => {
+ it('should return false if profile has no patterns', () => {
+ const profile = new Profile();
+ profile.patterns = [];
+ expect(service.profileMatchesHost(profile, 'example.com')).toBe(false);
+ });
+
+ it('should return true if any pattern matches', () => {
+ const profile = new Profile();
+ profile.patterns = [
+ { pattern: 'other.com', enabled: true, type: 'wildcard' },
+ { pattern: 'example.com', enabled: true, type: 'wildcard' }
+ ];
+ expect(service.profileMatchesHost(profile, 'example.com')).toBe(true);
+ });
+
+ it('should skip disabled patterns', () => {
+ const profile = new Profile();
+ profile.patterns = [
+ { pattern: 'example.com', enabled: false, type: 'wildcard' }
+ ];
+ expect(service.profileMatchesHost(profile, 'example.com')).toBe(false);
+ });
+ });
+
+ describe('findMatchingProfile', () => {
+ it('should return null if no profiles', () => {
+ expect(service.findMatchingProfile('example.com', [])).toBeNull();
+ });
+
+ it('should return first matching profile', () => {
+ const profile1 = new Profile();
+ profile1.profile_id = 1;
+ profile1.name = 'Profile 1';
+ profile1.patterns = [
+ { pattern: 'example.com', enabled: true, type: 'wildcard' }
+ ];
+
+ const profile2 = new Profile();
+ profile2.profile_id = 2;
+ profile2.name = 'Profile 2';
+ profile2.patterns = [
+ { pattern: 'example.com', enabled: true, type: 'wildcard' }
+ ];
+
+ const result = service.findMatchingProfile('example.com', [profile1, profile2]);
+ expect(result).toBe(profile1);
+ });
+
+ it('should return null if no patterns match', () => {
+ const profile = new Profile();
+ profile.patterns = [
+ { pattern: 'other.com', enabled: true, type: 'wildcard' }
+ ];
+
+ expect(service.findMatchingProfile('example.com', [profile])).toBeNull();
+ });
+
+ it('should handle complex patterns', () => {
+ const profile = new Profile();
+ profile.profile_id = 1;
+ profile.patterns = [
+ { pattern: '*.google.com', enabled: true, type: 'wildcard' }
+ ];
+
+ expect(service.findMatchingProfile('mail.google.com', [profile])).toBe(profile);
+ expect(service.findMatchingProfile('www.google.com', [profile])).toBe(profile);
+ expect(service.findMatchingProfile('google.com', [profile])).toBeNull();
+ });
+ });
+});
From 383f1df5143ab5306f1db24a6dd372eb20cd9a8f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 09:58:10 +0000
Subject: [PATCH 5/9] Add FormsModule and IonTextarea to profile module for
pattern textarea
Co-authored-by: NoelLH <3274454+NoelLH@users.noreply.github.com>
---
src/app/profile/profile.module.ts | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/app/profile/profile.module.ts b/src/app/profile/profile.module.ts
index 3f93023..ddecb6f 100644
--- a/src/app/profile/profile.module.ts
+++ b/src/app/profile/profile.module.ts
@@ -1,14 +1,15 @@
import { RouterModule } from '@angular/router';
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
-import { ReactiveFormsModule } from '@angular/forms';
-import { IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, IonContent, IonCard, IonCardContent, IonList, IonItem, IonInput, IonSelect, IonSelectOption, IonRange, IonToggle, IonActionSheet, IonFooter } from '@ionic/angular/standalone';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
+import { IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, IonContent, IonCard, IonCardContent, IonList, IonItem, IonInput, IonSelect, IonSelectOption, IonRange, IonToggle, IonActionSheet, IonFooter, IonTextarea } from '@ionic/angular/standalone';
import { ProfilePageComponent } from './profile.page';
@NgModule({
imports: [
CommonModule,
+ FormsModule,
ReactiveFormsModule,
RouterModule.forChild([{ path: '', component: ProfilePageComponent }]),
IonHeader,
@@ -28,7 +29,8 @@ import { ProfilePageComponent } from './profile.page';
IonRange,
IonToggle,
IonActionSheet,
- IonFooter
+ IonFooter,
+ IonTextarea
],
declarations: [ProfilePageComponent]
})
From 23752b638646da4d3f7c0479c4c9dc682abafc54 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 09:59:38 +0000
Subject: [PATCH 6/9] Address code review feedback: improve comments and regex
readability
Co-authored-by: NoelLH <3274454+NoelLH@users.noreply.github.com>
---
src/app/home/home.page.ts | 6 ++++--
src/app/import.service.ts | 2 +-
src/app/pattern-matcher.service.ts | 8 ++++----
src/app/profile/profile.page.ts | 4 ++--
4 files changed, 11 insertions(+), 9 deletions(-)
diff --git a/src/app/home/home.page.ts b/src/app/home/home.page.ts
index ad32f6a..6690ab4 100644
--- a/src/app/home/home.page.ts
+++ b/src/app/home/home.page.ts
@@ -148,6 +148,8 @@ export class HomePageComponent implements OnInit {
/**
* Auto-select a profile based on URL pattern matching
+ * Note: Auto-selected profile is temporary and won't persist to storage
+ * unless the user manually selects it or generates a password with it
*/
private autoSelectProfileForHost(settings: SettingsAdvanced) {
const matchingProfile = this.patternMatcher.findMatchingProfile(this.input.host, settings.profiles);
@@ -155,8 +157,8 @@ export class HomePageComponent implements OnInit {
if (matchingProfile && matchingProfile.profile_id !== this.input.active_profile_id) {
this.input.active_profile_id = matchingProfile.profile_id;
settings.setActiveProfile(matchingProfile.profile_id);
- // Note: We don't save settings here to avoid constant writes as user types
- // The profile will be used for this session but won't persist unless user manually selects it
+ // Note: We don't save settings here to avoid constant writes as user types.
+ // The profile selection is temporary for this session.
}
}
diff --git a/src/app/import.service.ts b/src/app/import.service.ts
index 537ff07..e9694f6 100644
--- a/src/app/import.service.ts
+++ b/src/app/import.service.ts
@@ -408,7 +408,7 @@ export class ImportService {
const result: Pattern[] = [];
patterns.forEach((pattern, index) => {
- // Only import enabled patterns as per requirements
+ // Only import enabled patterns per Chrome PasswordMaker Pro RDF specification
if (patternEnabled[index] === 'true' && pattern) {
result.push({
pattern,
diff --git a/src/app/pattern-matcher.service.ts b/src/app/pattern-matcher.service.ts
index 0a9d8bc..f4bcbd7 100644
--- a/src/app/pattern-matcher.service.ts
+++ b/src/app/pattern-matcher.service.ts
@@ -64,13 +64,13 @@ export class PatternMatcherService {
const regex = new RegExp(pattern.pattern);
return regex.test(host);
} else {
- // For wildcard patterns, convert to regex
- // Escape special regex characters except *
- const regexPattern = pattern.pattern
+ // For wildcard patterns, convert to regex by escaping special regex chars
+ // except asterisk (*) which we convert to .* for wildcard matching
+ const escapedPattern = pattern.pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
- const regex = new RegExp(`^${regexPattern}$`, 'i'); // Case-insensitive
+ const regex = new RegExp(`^${escapedPattern}$`, 'i'); // Case-insensitive
return regex.test(host);
}
} catch (error) {
diff --git a/src/app/profile/profile.page.ts b/src/app/profile/profile.page.ts
index c4cf50c..625a2f0 100644
--- a/src/app/profile/profile.page.ts
+++ b/src/app/profile/profile.page.ts
@@ -204,8 +204,8 @@ export class ProfilePageComponent implements OnInit {
return [];
}
- // Split by whitespace or line breaks
- const lines = text.split(/[\s\n]+/).filter(line => line.trim() !== '');
+ // Split by whitespace (includes spaces, tabs, newlines)
+ const lines = text.split(/\s+/).filter(line => line.trim() !== '');
return lines.map(line => {
const trimmed = line.trim();
From 38c02d264e12d26f81413abc6c863ef43608b082 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 10:17:21 +0000
Subject: [PATCH 7/9] Fix test failures: add proper XML namespaces and
FormsModule imports
Co-authored-by: NoelLH <3274454+NoelLH@users.noreply.github.com>
---
src/app/import.service.spec.ts | 63 ++++++++++++++-----------
src/app/pattern-matcher.service.spec.ts | 5 ++
src/app/profile/profile.page.spec.ts | 6 ++-
src/karma.conf.js | 8 +++-
4 files changed, 52 insertions(+), 30 deletions(-)
diff --git a/src/app/import.service.spec.ts b/src/app/import.service.spec.ts
index 16b37be..40eb39c 100644
--- a/src/app/import.service.spec.ts
+++ b/src/app/import.service.spec.ts
@@ -18,17 +18,20 @@ describe('ImportService', () => {
describe('parseSitePatterns', () => {
it('should parse wildcard patterns from RDF', () => {
- const xmlString = `
-
+ const xmlString = `
+
+
+
`;
const parser = new DOMParser();
@@ -53,13 +56,16 @@ describe('ImportService', () => {
});
it('should parse regex patterns from RDF', () => {
- const xmlString = `
-
+ const xmlString = `
+
+
+
`;
const parser = new DOMParser();
@@ -74,15 +80,18 @@ describe('ImportService', () => {
});
it('should skip disabled patterns', () => {
- const xmlString = `
-
+ const xmlString = `
+
+
+
`;
const parser = new DOMParser();
diff --git a/src/app/pattern-matcher.service.spec.ts b/src/app/pattern-matcher.service.spec.ts
index 84dada3..d1e9709 100644
--- a/src/app/pattern-matcher.service.spec.ts
+++ b/src/app/pattern-matcher.service.spec.ts
@@ -91,7 +91,12 @@ describe('PatternMatcherService', () => {
enabled: true,
type: 'regex'
};
+
+ // Suppress console.error for this test
+ const consoleErrorSpy = spyOn(console, 'error');
+
expect(service['patternMatches'](pattern, 'example.com')).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalled();
});
});
diff --git a/src/app/profile/profile.page.spec.ts b/src/app/profile/profile.page.spec.ts
index da247cf..461bccd 100644
--- a/src/app/profile/profile.page.spec.ts
+++ b/src/app/profile/profile.page.spec.ts
@@ -1,7 +1,7 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
-import { ReactiveFormsModule } from '@angular/forms';
+import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { CloudSettings } from '@awesome-cordova-plugins/cloud-settings/ngx';
-import { IonInput, IonSelect, IonToggle, provideIonicAngular } from '@ionic/angular/standalone';
+import { IonInput, IonSelect, IonToggle, IonTextarea, provideIonicAngular } from '@ionic/angular/standalone';
import { Drivers } from '@ionic/storage';
import { IonicStorageModule } from '@ionic/storage-angular';
@@ -17,9 +17,11 @@ describe('ProfilePageComponent', () => {
TestBed.configureTestingModule({
declarations: [ProfilePageComponent],
imports: [
+ FormsModule,
IonInput,
IonSelect,
IonToggle,
+ IonTextarea,
IonicStorageModule.forRoot({
driverOrder: [CapacitorSqliteDriverService._driver, Drivers.IndexedDB],
}),
diff --git a/src/karma.conf.js b/src/karma.conf.js
index adaeef7..5a21358 100644
--- a/src/karma.conf.js
+++ b/src/karma.conf.js
@@ -29,7 +29,13 @@ module.exports = function (config) {
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
- browsers: ['Chrome'],
+ browsers: ['ChromeHeadlessNoSandbox'],
+ customLaunchers: {
+ ChromeHeadlessNoSandbox: {
+ base: 'ChromeHeadless',
+ flags: ['--no-sandbox']
+ }
+ },
singleRun: false
});
};
From f73ee08ed6f9b4d62e6436390fde0dc8172396ea Mon Sep 17 00:00:00 2001
From: Noel Light-Hilary
Date: Mon, 26 Jan 2026 10:31:31 +0000
Subject: [PATCH 8/9] Set valid textarea label colour to primary
---
src/global.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/global.scss b/src/global.scss
index 2883d8d..a2da385 100644
--- a/src/global.scss
+++ b/src/global.scss
@@ -14,7 +14,7 @@
@import "@ionic/angular/css/palettes/dark.system.css";
:root {
- ion-input, ion-select {
+ ion-input, ion-select, ion-textarea {
--highlight-color-valid: var(--ion-color-primary);
}
From 468ad852746042a10d86e5aa23e62a669236edad Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Mon, 26 Jan 2026 11:18:19 +0000
Subject: [PATCH 9/9] Add domain extraction for pattern matching when
domain_only is true
Co-authored-by: NoelLH <3274454+NoelLH@users.noreply.github.com>
---
src/app/pattern-matcher.service.spec.ts | 29 +++++++++++++++++++++++++
src/app/pattern-matcher.service.ts | 5 ++++-
2 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/src/app/pattern-matcher.service.spec.ts b/src/app/pattern-matcher.service.spec.ts
index d1e9709..4e90fe7 100644
--- a/src/app/pattern-matcher.service.spec.ts
+++ b/src/app/pattern-matcher.service.spec.ts
@@ -123,6 +123,35 @@ describe('PatternMatcherService', () => {
];
expect(service.profileMatchesHost(profile, 'example.com')).toBe(false);
});
+
+ it('should extract domain when domain_only is true', () => {
+ const profile = new Profile();
+ profile.domain_only = true;
+ profile.patterns = [
+ { pattern: '*.example.org', enabled: true, type: 'wildcard' }
+ ];
+
+ // Should match when input is a full URL
+ expect(service.profileMatchesHost(profile, 'https://test.example.org/asdf')).toBe(true);
+ expect(service.profileMatchesHost(profile, 'http://api.example.org/path')).toBe(true);
+
+ // Should also match when input is just a domain
+ expect(service.profileMatchesHost(profile, 'test.example.org')).toBe(true);
+ });
+
+ it('should not extract domain when domain_only is false', () => {
+ const profile = new Profile();
+ profile.domain_only = false;
+ profile.patterns = [
+ { pattern: 'https://test.example.org/asdf', enabled: true, type: 'wildcard' }
+ ];
+
+ // Should match the full URL
+ expect(service.profileMatchesHost(profile, 'https://test.example.org/asdf')).toBe(true);
+
+ // Should not match just the domain
+ expect(service.profileMatchesHost(profile, 'test.example.org')).toBe(false);
+ });
});
describe('findMatchingProfile', () => {
diff --git a/src/app/pattern-matcher.service.ts b/src/app/pattern-matcher.service.ts
index f4bcbd7..d61dac4 100644
--- a/src/app/pattern-matcher.service.ts
+++ b/src/app/pattern-matcher.service.ts
@@ -42,8 +42,11 @@ export class PatternMatcherService {
return false;
}
+ // If domain_only is checked, extract domain from host before matching
+ const hostToMatch = profile.domain_only ? this.extractDomain(host) : host;
+
return profile.patterns.some(pattern =>
- pattern.enabled && this.patternMatches(pattern, host)
+ pattern.enabled && this.patternMatches(pattern, hostToMatch)
);
}