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..6690ab4 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,36 @@ 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
+ * 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);
+
+ 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 selection is temporary for this session.
+ }
+ }
+
+ /**
+ * 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/import.service.spec.ts b/src/app/import.service.spec.ts
new file mode 100644
index 0000000..40eb39c
--- /dev/null
+++ b/src/app/import.service.spec.ts
@@ -0,0 +1,220 @@
+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/import.service.ts b/src/app/import.service.ts
index 571b3d4..e9694f6 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 per Chrome PasswordMaker Pro RDF specification
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/pattern-matcher.service.spec.ts b/src/app/pattern-matcher.service.spec.ts
new file mode 100644
index 0000000..4e90fe7
--- /dev/null
+++ b/src/app/pattern-matcher.service.spec.ts
@@ -0,0 +1,202 @@
+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'
+ };
+
+ // Suppress console.error for this test
+ const consoleErrorSpy = spyOn(console, 'error');
+
+ expect(service['patternMatches'](pattern, 'example.com')).toBe(false);
+ expect(consoleErrorSpy).toHaveBeenCalled();
+ });
+ });
+
+ 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);
+ });
+
+ 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', () => {
+ 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();
+ });
+ });
+});
diff --git a/src/app/pattern-matcher.service.ts b/src/app/pattern-matcher.service.ts
new file mode 100644
index 0000000..d61dac4
--- /dev/null
+++ b/src/app/pattern-matcher.service.ts
@@ -0,0 +1,109 @@
+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;
+ }
+
+ // 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, hostToMatch)
+ );
+ }
+
+ /**
+ * 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 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(`^${escapedPattern}$`, '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;
+ }
+ }
+}
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]
})
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.
+
{
TestBed.configureTestingModule({
declarations: [ProfilePageComponent],
imports: [
+ FormsModule,
IonInput,
IonSelect,
IonToggle,
+ IonTextarea,
IonicStorageModule.forRoot({
driverOrder: [CapacitorSqliteDriverService._driver, Drivers.IndexedDB],
}),
diff --git a/src/app/profile/profile.page.ts b/src/app/profile/profile.page.ts
index f9f0a93..625a2f0 100644
--- a/src/app/profile/profile.page.ts
+++ b/src/app/profile/profile.page.ts
@@ -5,6 +5,7 @@ import { addIcons } from 'ionicons';
import { close, key, informationCircleOutline, warning, checkmarkCircleOutline, trashOutline } from 'ionicons/icons';
import { Profile } from '../../models/Profile';
+import { Pattern } from '../../models/Pattern';
import { SettingsService } from '../settings.service';
@Component({
@@ -36,6 +37,7 @@ export class ProfilePageComponent implements OnInit {
private profileId: number;
private lastCharacterSetPreset?: string;
+ patternsText = ''; // Text representation of patterns for UI
constructor() {
this.profile = this.formBuilder.group({
@@ -80,6 +82,11 @@ export class ProfilePageComponent implements OnInit {
this.profileId = formValues.profile_id;
+ // Convert patterns array to text for UI
+ if (this.profileModel.patterns && this.profileModel.patterns.length > 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 (includes spaces, tabs, newlines)
+ const lines = text.split(/\s+/).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/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);
}
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
});
};
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[] = [];
}