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) ); }