diff --git a/docs/rules-v5-contract.md b/docs/rules-v5-contract.md index 42035a9a..2f326680 100644 --- a/docs/rules-v5-contract.md +++ b/docs/rules-v5-contract.md @@ -66,8 +66,17 @@ Portable additionally contains `core.json`: Android contains `rules.db` with `PRAGMA user_version=5` and `rules_table(_id INTEGER PRIMARY KEY, name TEXT NOT NULL, label TEXT NOT NULL, type INTEGER NOT NULL, iconIndex INTEGER NOT NULL, isRegexRule INTEGER NOT NULL, -regexName TEXT, priority INTEGER NOT NULL)`. -These are exactly the legacy seven columns plus `priority`, with 4096-byte pages. +regexName TEXT, priority INTEGER NOT NULL, labelEn TEXT)`. +These are the legacy seven columns plus `priority` and nullable `labelEn`, with +4096-byte pages. `label` always preserves the original matcher label. `labelEn` +comes only from the effective matcher detail locale `en` (whole `detailData` +override if present, otherwise library `data`), and only when `hasDetail` is true. +Missing/blank English or English identical to `label` is stored as NULL. No +translation is invented, and an override without English never borrows the library +English label. Readers show `label` for persisted Chinese detail language; all +other languages use `labelEn` with fallback to `label`. Older eight-column +databases remain readable by falling back to `label`. Canonical editing format, +portable fields and legacy seven-column data stay unchanged. Rows retain their legacy integer IDs. Query regex rows with explicit `ORDER BY priority, _id`. The Android DB has no `uuid`, `iconId`, `detailPath`, or `isSimpleColorIcon` columns: these unused Android fields belong to canonical and @@ -76,7 +85,7 @@ endpoints and the selected GitHub/GitLab root; SDK UUID comes from cloud detail Android icons and their monochrome flags use Bundle drawable/IconResMap only. Independent chart SVG support is unchanged. This compact layout replaces the unpublished local 45 preview only; matching schema/user_version stays 5. Reader -validation must require these eight columns before consuming the refreshed lock. +validation accepts the original eight columns and this nine-column extension. Legacy ZIP contains `cloud/rules/v4/rules.db` (original seven columns), `cloud/md5/v4` (`{version,count}`), and legacy detail paths. Existing checked-in diff --git a/tests/test_rules.py b/tests/test_rules.py index a1b23532..faa871e8 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -62,15 +62,45 @@ def test_reproducible_build_and_versioning(self): with contextlib.closing(sqlite3.connect(db_path)) as db: self.assertEqual(5,db.execute('PRAGMA user_version').fetchone()[0]) columns=[row[1] for row in db.execute('PRAGMA table_info(rules_table)')] - self.assertEqual(['_id','name','label','type','iconIndex','isRegexRule','regexName','priority'],columns) + self.assertEqual(['_id','name','label','type','iconIndex','isRegexRule','regexName','priority','labelEn'],columns) self.assertEqual(4096,db.execute('PRAGMA page_size').fetchone()[0]) - expected=[(r['id'],r['name'],r['label'],r['type'],r['iconIndex'],int(r['isRegexRule']),r['regexName'],r['priority']) for r in sorted(self.rows,key=lambda r:r['id'])] + expected=[(r['id'],r['name'],r['label'],r['type'],r['iconIndex'],int(r['isRegexRule']),r['regexName'],r['priority'],rules.english_label(r,self.common)) for r in sorted(self.rows,key=lambda r:r['id'])] self.assertEqual(expected,db.execute('select * from rules_table order by _id').fetchall()) self.assertEqual([r['id'] for r in self.rows],[r[0] for r in db.execute('select _id from rules_table order by priority,_id')]) self.assertEqual(self.rows,json.loads(portable.read('core.json'))['rules']) self.assertEqual('ok',db.execute('PRAGMA integrity_check').fetchone()[0]) self.assertEqual(len(self.rows),db.execute('select count(*) from rules_table').fetchone()[0]) + def test_english_labels_use_effective_matcher_details(self): + with tempfile.TemporaryDirectory() as tmp: + root=Path(tmp);shutil.copytree(ROOT/'libraries',root/'libraries');shutil.copytree(ROOT/'icons',root/'icons') + path=root/'libraries/AEF9680F-4A43-4EDC-A5B8-8119D23BCD21.json' + lib=rules.read(path) + def localized(label):return [{'locale':'en','data':{'label':label}}] + lib['data']=localized('Library English') + source=copy.deepcopy(lib['matchers'][0]);lib['matchers']=[] + scenarios=[('inherited',True,None,'Original','Library English'), + ('override',True,localized('Specific English'),'Original','Specific English'), + ('missing',True,[{'locale':'zh-Hans','data':{'label':'中文'}}],'Original',None), + ('same',True,localized('Same'),'Same',None), + ('blank',True,localized(' '),'Original',None), + ('no-detail',False,None,'Original',None)] + expected={} + for offset,(name,has_detail,override,label,result) in enumerate(scenarios): + m=copy.deepcopy(source);m.update(id=100000+offset,name='test.'+name,label=label,hasDetail=has_detail,legacyPath=None,examples={'positive':[],'negative':[]}) + m.pop('detailData',None) + if override is not None:m['detailData']=override + lib['matchers'].append(m);expected[m['id']]=result + path.write_bytes(rules.encoded(lib)) + rows,details,_,_=rules.load_source(root) + db_path=root/'check.db';db_path.write_bytes(rules.database(rows,True,details)) + with contextlib.closing(sqlite3.connect(db_path)) as db: + actual=dict(db.execute('select _id,labelEn from rules_table where _id>=100000')) + self.assertEqual(expected,actual) + db_path.write_bytes(rules.database(rows,False,details)) + with contextlib.closing(sqlite3.connect(db_path)) as db: + self.assertEqual(7,len(db.execute('PRAGMA table_info(rules_table)').fetchall())) + def test_compact_database_keeps_priority_independent_of_id(self): rows=copy.deepcopy(self.rows[:2]) rows[0]['priority']=100 diff --git a/tools/rules.py b/tools/rules.py index 2e994fac..91ed5f72 100644 --- a/tools/rules.py +++ b/tools/rules.py @@ -21,7 +21,7 @@ TAGS = {'svg','g','path','defs','clipPath','linearGradient','radialGradient','stop'} ATTRS = {'width','height','viewBox','fill','fill-opacity','stroke','stroke-width','stroke-opacity','stroke-linecap','stroke-linejoin','stroke-miterlimit','fill-rule','clip-rule','d','transform','id','clip-path','x1','x2','y1','y2','cx','cy','r','fx','fy','gradientUnits','gradientTransform','spreadMethod','offset','stop-color','stop-opacity','opacity'} LEGACY_SQL = '''CREATE TABLE rules_table(_id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL, label TEXT NOT NULL, type INTEGER NOT NULL, iconIndex INTEGER NOT NULL, isRegexRule INTEGER NOT NULL, regexName TEXT)''' -V5_SQL = LEGACY_SQL[:-1]+', priority INTEGER NOT NULL)' +V5_SQL = LEGACY_SQL[:-1]+', priority INTEGER NOT NULL, labelEn TEXT)' def fail(condition, message): if not condition: raise ValueError(message) @@ -202,7 +202,15 @@ def add_legacy(path,payload): common={**icons,**details,'icons/index.json':encoded(icon_index),'matching-fixtures.json':encoded(fixtures)} return rules,common,legacy,digest.hexdigest() -def database(rules, v5): +def english_label(rule, details): + path=rule['detailPath'] + if path is None:return None + data=json.loads(details[path])['data'] + label=next((item['data'].get('label') for item in data if item['locale']=='en'),None) + return label if isinstance(label,str) and label.strip() and label!=rule['label'] else None + + +def database(rules, v5, details=None): with tempfile.TemporaryDirectory() as tmp: path=Path(tmp)/'rules.db' with closing(sqlite3.connect(path)) as db, db: @@ -210,7 +218,7 @@ def database(rules, v5): if v5: db.execute('PRAGMA user_version=5') for r in sorted(rules,key=lambda r:r['id']): row=[r['id'],r['name'],r['label'],r['type'],r['iconIndex'],int(r['isRegexRule']),r['regexName']] - if v5:row.append(r['priority']) + if v5:row += [r['priority'],english_label(r,details) if details is not None else None] db.execute('INSERT INTO rules_table VALUES ('+','.join('?' for _ in row)+')',row) return path.read_bytes() @@ -244,7 +252,7 @@ def build(root, output, version=None, previous=None, revision=None): fail(re.fullmatch('[0-9a-f]{40}',revision) is not None,'sourceRevision must be a full Git SHA') metadata=dict(schemaVersion=5,dataVersion=version,sourceRevision=revision,compilerRevision=compiler,contentSha256=content,ruleCount=len(rules),minimumReader=dict(android=5,portable=5)) common['metadata.json']=encoded(metadata) - payloads={'android':{'metadata.json':encoded(metadata),'rules.db':database(rules,True)},'portable':{**common,'core.json':encoded(dict(schemaVersion=5,rules=rules))},'legacy':{**legacy,'cloud/rules/v4/rules.db':database(rules,False),'cloud/md5/v4':encoded(dict(version=version,count=len(rules)))}} + payloads={'android':{'metadata.json':encoded(metadata),'rules.db':database(rules,True,common)},'portable':{**common,'core.json':encoded(dict(schemaVersion=5,rules=rules))},'legacy':{**legacy,'cloud/rules/v4/rules.db':database(rules,False),'cloud/md5/v4':encoded(dict(version=version,count=len(rules)))}} manifest={**metadata,'artifacts':{}} outputs={} for kind,files in payloads.items():