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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions docs/rules-v5-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
34 changes: 32 additions & 2 deletions tests/test_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions tools/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -202,15 +202,23 @@ 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:
db.execute('PRAGMA page_size=4096');db.execute(V5_SQL if v5 else LEGACY_SQL)
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()

Expand Down Expand Up @@ -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():
Expand Down
Loading