اضافه شدن قابلیت مقایسه شماتیک
This commit is contained in:
@@ -23,5 +23,8 @@ jobs:
|
||||
- run: .venv/bin/python scripts/concurrency-smoke.py .runtime/fixtures/Channel.SchDoc
|
||||
env:
|
||||
API_KEY: replace-with-a-long-random-api-key-at-least-32-characters
|
||||
- run: .venv/bin/python scripts/compare-smoke.py .runtime/fixtures/Channel.SchDoc .runtime/fixtures/Channel.SchDoc
|
||||
env:
|
||||
API_KEY: replace-with-a-long-random-api-key-at-least-32-characters
|
||||
- if: always()
|
||||
run: docker compose -p altium-ci down
|
||||
|
||||
+6
-1
@@ -1,5 +1,10 @@
|
||||
{$DOMAIN} {
|
||||
request_body {
|
||||
@comparison path /api/compare /api/v1/comparisons
|
||||
request_body @comparison {
|
||||
max_size 65MiB
|
||||
}
|
||||
@single not path /api/compare /api/v1/comparisons
|
||||
request_body @single {
|
||||
max_size 33MB
|
||||
}
|
||||
reverse_proxy api:8000
|
||||
|
||||
@@ -90,6 +90,12 @@ Error responses use `{"success": false, "error": {"code": "…", "message": "…
|
||||
|
||||
`unlisted` is an explicit alternative: anyone with its 192-bit random identifier can read the result until expiry. There is no searchable gallery. Every access is checked on the server. Link lifetime is configurable and the worker removes expired database rows and artifacts in batches. After physical cleanup the endpoint returns 404. An already downloaded document cannot be revoked from a recipient's device. The reference viewer clears itself at expiry.
|
||||
|
||||
## Schematic Diff Viewer
|
||||
|
||||
Open `/compare` to upload an original and modified SchDoc, or call `POST /api/v1/comparisons` (alias `/api/compare`) with multipart fields `old_file` and `new_file` and the existing `X-API-Key`. It returns HTTP 202 with a private, expiring `/compare/{id}#token=…` URL. Poll `GET /api/v1/comparisons/{id}` using `X-Viewer-Token` until completed to receive summary counts and native-object differences.
|
||||
|
||||
The workspace displays original/modified SVGs side by side with synchronized zoom/pan, optional change boxes, filtering, changes-only mode, navigation and property details. It reuses the native parser, renderer, queue, resource-limited worker and local/S3 storage without new packages or database migrations. Net labels and wire geometry are compared; no compiled electrical equivalence is claimed. See [Schematic Diff documentation](docs/SCHEMATIC_DIFF.md) for matching rules, API examples, deployment limits and tests.
|
||||
|
||||
## Storage and configuration
|
||||
|
||||
Local files are private, written atomically with generated keys. Client filenames never determine storage paths. For private S3-compatible storage set the same `S3_BUCKET`, `S3_ENDPOINT_URL`, AWS credentials and region on API and workers; no bucket ACL or public URL is created. Prefer workload credentials/IAM and configure bucket encryption/lifecycle at the provider. On a multi-host deployment use S3; a Docker named volume is shared only on one host. Storage/database orphan reconciliation after a machine failure is an operational maintenance task; normal completion, retries and expiry cleanup are implemented.
|
||||
|
||||
@@ -15,8 +15,10 @@ def cleanup(batch=100):
|
||||
for job in db.scalars(q):
|
||||
try:
|
||||
storage().delete(job.input_key)
|
||||
if job.input_key.endswith('/comparison.json'):
|
||||
for side in ('old','new'): storage().delete(f'{job.id}/{side}.SchDoc')
|
||||
for attempt in (1,2):
|
||||
for name in ('data.json','drawing.svg'):
|
||||
for name in ('data.json','drawing.svg','old.svg','new.svg'):
|
||||
storage().delete(f'{job.id}/attempt-{attempt}/{name}')
|
||||
db.delete(job); removed+=1
|
||||
except Exception: log.exception('Expired artifact cleanup failed for %s',job.id)
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Comparison routes using the existing Job, storage, authentication and queue."""
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Annotated,Literal
|
||||
from fastapi import File,Form,Header,UploadFile
|
||||
from fastapi.responses import FileResponse,JSONResponse,Response
|
||||
from sqlalchemy import select,func,text
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .config import settings
|
||||
from .db import Job,sessions
|
||||
from .storage import storage
|
||||
from .uploads import read_schematic
|
||||
from .models.api import ComparisonStatus,ComparisonUploadResponse
|
||||
|
||||
FRONTEND=Path(__file__).resolve().parents[2]/'frontend'
|
||||
|
||||
|
||||
def register_comparison_routes(app, *, authorize, require_api, error):
|
||||
def comparison(identifier,key,token):
|
||||
job=authorize(identifier,key,token,comparison=True)
|
||||
if not job.input_key.endswith('/comparison.json'):error('not_found','Comparison not found',404)
|
||||
return job
|
||||
|
||||
def state(job):
|
||||
result={'success':job.status!='failed','id':job.id,'comparison_id':job.id,'status':job.status,
|
||||
'url':settings().public_url+'/compare/'+job.id,'viewer_url':settings().public_url+'/compare/'+job.id,
|
||||
'status_url':settings().public_url+'/api/v1/comparisons/'+job.id,
|
||||
'expires_at':job.expires_at,'access':job.access}
|
||||
if job.summary:result.update(json.loads(job.summary))
|
||||
if job.error_code:result['error']={'code':job.error_code,'message':job.error_message}
|
||||
return result
|
||||
|
||||
@app.post('/api/compare',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
|
||||
@app.post('/api/v1/comparisons',status_code=202,response_model=ComparisonUploadResponse,response_model_exclude_unset=True,tags=['Comparisons'])
|
||||
async def create(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
|
||||
access:Annotated[Literal['private','unlisted'],Form()]='private',
|
||||
expires_in:Annotated[int|None,Form(gt=0)]=None,
|
||||
x_api_key:Annotated[str|None,Header()]=None):
|
||||
require_api(x_api_key)
|
||||
ttl=expires_in or settings().link_ttl
|
||||
if ttl>settings().link_ttl:error('invalid_expiry','expires_in exceeds the configured link lifetime',422)
|
||||
try:
|
||||
old_name,old=await read_schematic(old_file)
|
||||
new_name,new=await read_schematic(new_file)
|
||||
finally:
|
||||
await old_file.close();await new_file.close()
|
||||
identifier=secrets.token_hex(24);token=secrets.token_urlsafe(32);now=time.time()
|
||||
job=Job(id=identifier,filename='Schematic comparison',status='queued',access=access,
|
||||
token_hash=hashlib.sha256(token.encode()).hexdigest(),created_at=now,updated_at=now,
|
||||
expires_at=now+ttl,attempt=0,input_key=identifier+'/comparison.json')
|
||||
files={identifier+'/old.SchDoc':old,identifier+'/new.SchDoc':new,
|
||||
job.input_key:json.dumps({'schema_version':'1.0','old_filename':old_name,'new_filename':new_name}).encode()}
|
||||
def persist():
|
||||
written=[]
|
||||
try:
|
||||
with sessions().begin() as db:
|
||||
if db.bind.dialect.name=='postgresql':db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
|
||||
pending=db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),Job.expires_at>now))
|
||||
if pending>=settings().max_pending:error('queue_full','Rendering queue is full; try again later',503)
|
||||
for key,body in files.items():
|
||||
try: storage().put(key,body)
|
||||
except Exception: error('storage_unavailable','Upload storage unavailable',503)
|
||||
written.append(key)
|
||||
db.add(job);db.flush()
|
||||
except Exception:
|
||||
for key in written:
|
||||
try:storage().delete(key)
|
||||
except Exception:pass
|
||||
raise
|
||||
try:await run_in_threadpool(persist)
|
||||
except OSError:error('storage_unavailable','Upload storage unavailable',503)
|
||||
result=state(job)
|
||||
if access=='private':
|
||||
result['viewer_token']=token
|
||||
result['viewer_url']+='#token='+token
|
||||
result['url']=result['viewer_url']
|
||||
return JSONResponse(result,status_code=202,headers={'Location':result['status_url'],'Retry-After':'1'})
|
||||
|
||||
@app.get('/api/compare/{identifier}',response_model=ComparisonStatus,response_model_exclude_unset=True,tags=['Comparisons'])
|
||||
@app.get('/api/v1/comparisons/{identifier}',response_model=ComparisonStatus,response_model_exclude_unset=True,tags=['Comparisons'])
|
||||
def get(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
job=comparison(identifier,x_api_key,x_viewer_token);result=state(job)
|
||||
if job.status=='completed':
|
||||
try:result.update(json.loads(storage().get(job.output_prefix+'/data.json')))
|
||||
except Exception:error('storage_unavailable','Comparison result unavailable',503)
|
||||
return result
|
||||
|
||||
@app.get('/api/v1/comparisons/{identifier}/svg/{side}',response_class=Response,tags=['Comparisons'])
|
||||
def svg(identifier:str,side:Literal['old','new'],x_api_key:Annotated[str|None,Header()]=None,
|
||||
x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
job=comparison(identifier,x_api_key,x_viewer_token)
|
||||
if job.status=='failed':error(job.error_code,job.error_message,422)
|
||||
if job.status!='completed':error('not_ready','Comparison is still in progress',409)
|
||||
try:body=storage().get(job.output_prefix+'/'+side+'.svg')
|
||||
except Exception:error('storage_unavailable','Comparison rendering unavailable',503)
|
||||
return Response(body,media_type='image/svg+xml')
|
||||
|
||||
@app.get('/compare',include_in_schema=False)
|
||||
def upload_page():return FileResponse(FRONTEND/'compare-upload.html')
|
||||
|
||||
@app.get('/compare/{identifier}',include_in_schema=False)
|
||||
def page(identifier:str):
|
||||
import re
|
||||
if not re.fullmatch('[a-f0-9]{48}',identifier):error('not_found','Comparison not found',404)
|
||||
return FileResponse(FRONTEND/'compare.html')
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Reuse the parser's resource boundary, geometry model and SVG serializer."""
|
||||
from . import parse_task # Sets the same subprocess resource limits, without running it.
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from .documents import parse_document
|
||||
from .diff import compare_documents
|
||||
from .parser.record_decoder import ParseError
|
||||
from .render import svg_document
|
||||
|
||||
|
||||
def run():
|
||||
source,destination,_,identifier=sys.argv[1:]
|
||||
dest=Path(destination)
|
||||
try:
|
||||
manifest=json.loads(Path(source).read_text())
|
||||
old=parse_document((dest/'old.SchDoc').read_bytes(),manifest['old_filename'],identifier+'-old',allow_empty=True)
|
||||
new=parse_document((dest/'new.SchDoc').read_bytes(),manifest['new_filename'],identifier+'-new',allow_empty=True)
|
||||
report=compare_documents(old,new)
|
||||
(dest/'data.json').write_text(json.dumps(report,ensure_ascii=False),encoding='utf-8')
|
||||
for side,doc in [('old',old),('new',new)]:
|
||||
annotations={c[f'{side}_id']:{'id':c['id'],'status':c['status'],'bounds':c[f'{side}_bounds']}
|
||||
for c in report['changes'] if c[f'{side}_id'] is not None}
|
||||
(dest/f'{side}.svg').write_bytes(svg_document(doc,annotations,id_prefix=side+'-'))
|
||||
result={'ok':True,'summary':{'summary':report['summary'],'total_changed':report['total_changed']}}
|
||||
except (ParseError,ValueError) as exc:
|
||||
result={'ok':False,'code':'invalid_schematic','message':str(exc)[:500]}
|
||||
except Exception:
|
||||
result={'ok':False,'code':'comparison_error','message':'The schematic comparison could not be completed'}
|
||||
(dest/'result.json').write_text(json.dumps(result),encoding='utf-8')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Structural SchDoc comparison. Physical record indexes are never cross-file identity."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from .models.schematic import Document
|
||||
|
||||
BOOKKEEPING = {'OWNERINDEX','OWNERINDEXADDITIONALLIST','INDEXINSHEET','UNIQUEID',
|
||||
'GRAPHICALLYLOCKED','LOCKED','READONLYSTATE','_RESOLVED_TEXT'}
|
||||
X_COORD = re.compile(r'^(LOCATION\.X|CORNER\.X|E?X\d+)$')
|
||||
Y_COORD = re.compile(r'^(LOCATION\.Y|CORNER\.Y|E?Y\d+)$')
|
||||
NET_TYPES = {17,18,25}
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False, separators=(',',':')).encode()).hexdigest()
|
||||
|
||||
|
||||
def normalized(properties, origin):
|
||||
result = {}
|
||||
keys = set(properties) | {k[:-5] for k in properties if k.endswith('_FRAC') and (X_COORD.match(k[:-5]) or Y_COORD.match(k[:-5]))}
|
||||
for key in sorted(keys):
|
||||
value = properties.get(key, '0')
|
||||
if key in BOOKKEEPING or (key.endswith('_FRAC') and (X_COORD.match(key[:-5]) or Y_COORD.match(key[:-5]))): continue
|
||||
if X_COORD.match(key) or Y_COORD.match(key):
|
||||
try:
|
||||
offset = origin[0] if X_COORD.match(key) else -origin[1]
|
||||
result[key] = round(float(value) + float(properties.get(key+'_FRAC',0))/100000 - offset, 8)
|
||||
except ValueError: result[key] = value
|
||||
else: result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class Entity:
|
||||
id: str
|
||||
kind: str
|
||||
uid: str
|
||||
reference: str
|
||||
position: tuple
|
||||
bounds: dict | None
|
||||
properties: dict
|
||||
signature: str
|
||||
primitive_ids: list
|
||||
record_type: int
|
||||
|
||||
|
||||
def entities(document):
|
||||
objects = {o.id:o for o in document.objects}
|
||||
children = defaultdict(list)
|
||||
for obj in document.objects:
|
||||
if obj.component_id: children[obj.component_id].append(obj)
|
||||
image_hashes = defaultdict(list)
|
||||
for primitive in document.primitives:
|
||||
if primitive.kind == 'image': image_hashes[primitive.object_id].append(digest(primitive.image_data))
|
||||
output = []
|
||||
for component in document.components:
|
||||
source = objects.get(component.id)
|
||||
origin = (component.position.x, component.position.y)
|
||||
records = []
|
||||
for obj in children[component.id]:
|
||||
record = normalized(obj.properties, origin)
|
||||
if image_hashes[obj.id]: record['_IMAGE_SHA256'] = image_hashes[obj.id]
|
||||
records.append(record)
|
||||
records.sort(key=digest)
|
||||
details = {key:getattr(component,key) for key in ('designator','value','comment','library_reference',
|
||||
'source_library','description','rotation','mirrored','current_part','parameters')}
|
||||
details['symbol_records'] = digest(records)
|
||||
output.append(Entity(component.id,'component',source.properties.get('UNIQUEID','').strip().upper() if source else '',
|
||||
f'{component.designator.upper()}:{component.current_part}' if component.designator else '',
|
||||
origin,component.bounds.model_dump() if component.primitive_ids else None, details,digest(details),component.primitive_ids,1))
|
||||
for obj in document.objects:
|
||||
if obj.component_id or obj.record_type in {31,39,44,45,46,47,48}: continue
|
||||
if not obj.visible and obj.record_type != 41: continue
|
||||
origin = (obj.position.x,obj.position.y)
|
||||
if 'LOCATION.X' not in obj.properties and 'LOCATION.Y' not in obj.properties and obj.connection_points:
|
||||
origin = (obj.connection_points[0].x,obj.connection_points[0].y)
|
||||
raw = obj.properties
|
||||
if obj.record_type in (26,27) and obj.connection_points:
|
||||
points = [(p.x,p.y) for p in obj.connection_points]
|
||||
points = min(points, list(reversed(points)))
|
||||
origin = points[0]
|
||||
raw = {k:v for k,v in raw.items() if not re.fullmatch(r'[XY]\d+(?:_FRAC)?',k)}
|
||||
for i,(x,y) in enumerate(points,1): raw.update({f'X{i}':str(x),f'Y{i}':str(-y)})
|
||||
properties = normalized(raw, origin)
|
||||
if image_hashes[obj.id]: properties['_IMAGE_SHA256'] = image_hashes[obj.id]
|
||||
output.append(Entity(obj.id,obj.kind,obj.properties.get('UNIQUEID','').strip().upper(),'',origin,
|
||||
obj.bounds.model_dump() if obj.primitive_ids else None,properties,digest(properties),obj.primitive_ids,obj.record_type))
|
||||
return output
|
||||
|
||||
|
||||
def match_entities(old, new):
|
||||
left = {e.id:e for e in old}; right = {e.id:e for e in new}
|
||||
pairs = []; ambiguous = 0
|
||||
# Stages only pair keys unique on BOTH sides. Coincident/duplicate objects
|
||||
# are not assigned arbitrary nearest neighbors or physical record indexes.
|
||||
stages = [
|
||||
('unique_id', lambda e:(e.kind,e.uid) if e.uid else None),
|
||||
('designator', lambda e:(e.kind,e.reference) if e.reference else None),
|
||||
('exact', lambda e:(e.kind,e.signature,e.position)),
|
||||
('structure', lambda e:(e.kind,e.signature)),
|
||||
('position', lambda e:(e.kind,e.position) if e.bounds is not None else None),
|
||||
]
|
||||
for method,key in stages:
|
||||
a=defaultdict(list); b=defaultdict(list)
|
||||
for e in left.values():
|
||||
k=key(e)
|
||||
if k is not None:a[k].append(e)
|
||||
for e in right.values():
|
||||
k=key(e)
|
||||
if k is not None:b[k].append(e)
|
||||
for k,values in a.items():
|
||||
candidates=b.get(k,[])
|
||||
if len(values)==len(candidates)==1:
|
||||
x,y=values[0],candidates[0]
|
||||
pairs.append((x,y,method));del left[x.id];del right[y.id]
|
||||
# Identical duplicates are a multiset, so reordering them is not a change.
|
||||
a=defaultdict(list);b=defaultdict(list)
|
||||
for e in left.values():a[(e.kind,e.signature,e.position)].append(e)
|
||||
for e in right.values():b[(e.kind,e.signature,e.position)].append(e)
|
||||
for key,values in a.items():
|
||||
for x,y in zip(values,b.get(key,[])):
|
||||
pairs.append((x,y,'identical_multiset'));del left[x.id];del right[y.id]
|
||||
a=defaultdict(int); b=defaultdict(int)
|
||||
for entity in left.values(): a[(entity.kind,entity.signature)]+=1
|
||||
for entity in right.values(): b[(entity.kind,entity.signature)]+=1
|
||||
ambiguous=sum(1 for k in a.keys() & b.keys() if a[k]>1 or b[k]>1)
|
||||
return pairs,list(left.values()),list(right.values()),ambiguous
|
||||
|
||||
|
||||
def compare_documents(old: Document, new: Document):
|
||||
if old.document_type != 'schematic' or new.document_type != 'schematic':
|
||||
raise ValueError('Only schematic documents can be compared')
|
||||
before,after = entities(old),entities(new)
|
||||
pairs,removed,added,ambiguous = match_entities(before,after)
|
||||
changes=[]
|
||||
def change(status,a,b,method='',fields=None):
|
||||
changes.append({'id':f'change-{len(changes)}','status':status,'kind':(b or a).kind,
|
||||
'label':(b or a).properties.get('designator') or (b or a).properties.get('TEXT') or (b or a).properties.get('NAME') or (b or a).kind,
|
||||
'old_id':a.id if a else None,'new_id':b.id if b else None,
|
||||
'old_bounds':a.bounds if a else None,'new_bounds':b.bounds if b else None,
|
||||
'old_primitive_ids':a.primitive_ids if a else [],'new_primitive_ids':b.primitive_ids if b else [],
|
||||
'match':method,'moved':bool(a and b and a.position!=b.position),
|
||||
'movement':{'dx':b.position[0]-a.position[0],'dy':b.position[1]-a.position[1]} if a and b else None,
|
||||
'fields':fields or []})
|
||||
for a,b,method in pairs:
|
||||
fields=[{'property':key,'old':a.properties.get(key),'new':b.properties.get(key)}
|
||||
for key in sorted(a.properties.keys()|b.properties.keys()) if a.properties.get(key)!=b.properties.get(key)]
|
||||
moved=a.position!=b.position
|
||||
if fields or moved:change('modified' if fields else 'moved',a,b,method,fields)
|
||||
for a in removed:change('removed',a,None)
|
||||
for b in added:change('added',None,b)
|
||||
sheet_fields=[{'property':key,'old':old.sheet.properties.get(key),'new':new.sheet.properties.get(key)}
|
||||
for key in sorted(old.sheet.properties.keys()|new.sheet.properties.keys())
|
||||
if key not in BOOKKEEPING and old.sheet.properties.get(key)!=new.sheet.properties.get(key)]
|
||||
if sheet_fields:
|
||||
changes.append({'id':f'change-{len(changes)}','status':'modified','kind':'sheet','label':'Sheet settings',
|
||||
'old_id':old.sheet.id,'new_id':new.sheet.id,'old_bounds':old.sheet.bounds.model_dump(),
|
||||
'new_bounds':new.sheet.bounds.model_dump(),'old_primitive_ids':[],'new_primitive_ids':[],
|
||||
'match':'sheet','moved':False,'movement':None,'fields':sheet_fields})
|
||||
def nets(items):
|
||||
result=defaultdict(list)
|
||||
for e in items:
|
||||
if e.record_type in NET_TYPES:
|
||||
name=e.properties.get('TEXT',e.properties.get('NAME','')).strip()
|
||||
if name:result[name].append(e.position)
|
||||
return {name:sorted(points) for name,points in result.items()}
|
||||
old_nets,new_nets=nets(before),nets(after)
|
||||
summary={status:sum(c['status']==status for c in changes) for status in ('added','removed','modified','moved')}
|
||||
return {'schema_version':'1.0','document_type':'schematic_comparison','summary':summary,'total_changed':len(changes),
|
||||
'old':{'filename':old.filename,'compatibility':old.metadata.get('compatibility'),'sha256':old.metadata.get('sha256'),'sheet':old.sheet.bounds.model_dump(),'bounds':old.bounds.model_dump(),'diagnostics':[d.model_dump() for d in old.diagnostics]},
|
||||
'new':{'filename':new.filename,'compatibility':new.metadata.get('compatibility'),'sha256':new.metadata.get('sha256'),'sheet':new.sheet.bounds.model_dump(),'bounds':new.bounds.model_dump(),'diagnostics':[d.model_dump() for d in new.diagnostics]},
|
||||
'changes':changes,'nets':{'added':sorted(new_nets.keys()-old_nets.keys()),'removed':sorted(old_nets.keys()-new_nets.keys()),
|
||||
'changed':sorted(name for name in old_nets.keys()&new_nets.keys() if old_nets[name]!=new_nets[name])},
|
||||
'limitations':['This is a structural/connection-graphics comparison, not a compiled electrical netlist.',
|
||||
'Ambiguous identities remain added/removed. Geometry/designator fallback matches are heuristic.',
|
||||
'Unsupported native objects retain the parser compatibility limits.'],
|
||||
'matching':{'matched':len(pairs),'unmatched_old':len(removed),'unmatched_new':len(added),'ambiguous_groups':ambiguous}}
|
||||
@@ -5,11 +5,11 @@ from .parser.schdoc_parser import parse_schdoc
|
||||
from .parser.record_decoder import ParseError
|
||||
|
||||
class DocumentParser(Protocol):
|
||||
def __call__(self, data: bytes, filename: str, identifier: str) -> Document: ...
|
||||
def __call__(self, data: bytes, filename: str, identifier: str, **options) -> Document: ...
|
||||
|
||||
PARSERS: dict[str, DocumentParser] = {'schematic': parse_schdoc}
|
||||
|
||||
def parse_document(data: bytes, filename: str, identifier: str, document_type='schematic') -> Document:
|
||||
def parse_document(data: bytes, filename: str, identifier: str, document_type='schematic', **options) -> Document:
|
||||
parser=PARSERS.get(document_type)
|
||||
if parser is None: raise ParseError('Unsupported document type')
|
||||
return parser(data,filename,identifier)
|
||||
return parser(data,filename,identifier,**options)
|
||||
|
||||
+13
-17
@@ -17,11 +17,11 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .config import settings
|
||||
from .db import Job, initialize, sessions
|
||||
from .parser.record_decoder import ParseError, detect_format
|
||||
from .storage import storage
|
||||
from .fonts import fonts, stylesheet
|
||||
from .models.api import UploadResponse, RenderStatus, ErrorEnvelope
|
||||
from .models.schematic import Document
|
||||
from .uploads import read_schematic
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
|
||||
ID = re.compile(r'^[a-f0-9]{48}$')
|
||||
@@ -54,7 +54,8 @@ class UploadLimit:
|
||||
def __init__(self, app): self.app = app
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope['type'] != 'http': return await self.app(scope, receive, send)
|
||||
limit = settings().max_upload_bytes + 1024 * 1024
|
||||
file_count = 2 if scope['path'] in ('/api/compare','/api/v1/comparisons') else 1
|
||||
limit = file_count * settings().max_upload_bytes + 1024 * 1024
|
||||
received = 0
|
||||
async def bounded_receive():
|
||||
nonlocal received
|
||||
@@ -70,7 +71,7 @@ app.add_middleware(UploadLimit)
|
||||
@app.middleware('http')
|
||||
async def security(request, call_next):
|
||||
# Reject unauthorized POST bodies before the multipart parser reads them.
|
||||
if request.method == 'POST' and request.url.path == '/api/v1/schematics/render':
|
||||
if request.method == 'POST' and request.url.path in ('/api/v1/schematics/render','/api/compare','/api/v1/comparisons'):
|
||||
if settings().api_key and not authenticated(request.headers.get('x-api-key')):
|
||||
return JSONResponse({'success': False, 'error': {'code': 'unauthorized', 'message': 'Provide a valid X-API-Key header'}}, 401)
|
||||
response = await call_next(request)
|
||||
@@ -108,10 +109,11 @@ def health():
|
||||
with sessions()() as db: db.execute(text('SELECT 1'))
|
||||
return {'status': 'ok'}
|
||||
|
||||
def authorize(identifier, key, token):
|
||||
def authorize(identifier, key, token, *, comparison=False):
|
||||
if not ID.fullmatch(identifier): error('not_found', 'Schematic not found', 404)
|
||||
with sessions()() as db: job = db.get(Job, identifier)
|
||||
if job is None: error('not_found', 'Schematic not found', 404)
|
||||
if job is None or job.input_key.endswith('/comparison.json') != comparison:
|
||||
error('not_found', 'Schematic not found', 404)
|
||||
# Private existence and expiry are not disclosed to unauthenticated callers.
|
||||
if job.access == 'private' and not (authenticated(key) or (token and hmac.compare_digest(job.token_hash, digest(token)))):
|
||||
error('not_found', 'Schematic not found', 404)
|
||||
@@ -135,19 +137,9 @@ async def upload(file: Annotated[UploadFile, File(description='Native binary OLE
|
||||
expires_in: Annotated[int | None, Form(gt=0, description='Link lifetime in seconds, up to configured maximum')] = None,
|
||||
x_api_key: Annotated[str | None, Header()] = None):
|
||||
require_api(x_api_key)
|
||||
name = (file.filename or '').replace('\\', '/').rsplit('/', 1)[-1]
|
||||
name = ''.join(c for c in name if c.isprintable())[:255]
|
||||
if not name.lower().endswith('.schdoc'): error('unsupported_file_type', 'Upload a .SchDoc file', 415)
|
||||
ttl = expires_in or settings().link_ttl
|
||||
if ttl > settings().link_ttl: error('invalid_expiry', 'expires_in exceeds the configured link lifetime', 422)
|
||||
body = bytearray()
|
||||
try:
|
||||
while chunk := await file.read(1024 * 1024):
|
||||
body.extend(chunk)
|
||||
if len(body) > settings().max_upload_bytes: error('upload_too_large', 'Schematic exceeds configured upload size', 413)
|
||||
finally: await file.close()
|
||||
try: detect_format(bytes(body))
|
||||
except ParseError as exc: error('invalid_schematic', str(exc), 422)
|
||||
name, body = await read_schematic(file)
|
||||
identifier = secrets.token_hex(24)
|
||||
token = secrets.token_urlsafe(32)
|
||||
now = time.time()
|
||||
@@ -209,7 +201,7 @@ def index(): return FileResponse(FRONTEND / 'index.html')
|
||||
|
||||
@app.get('/assets/{name}', include_in_schema=False)
|
||||
def asset(name: str):
|
||||
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js'}: error('not_found', 'Asset not found', 404)
|
||||
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js','compare.js','compare.css','compare-upload.js','interactions.js'}: error('not_found', 'Asset not found', 404)
|
||||
return FileResponse(FRONTEND / name)
|
||||
|
||||
@app.get('/docs', include_in_schema=False)
|
||||
@@ -228,3 +220,7 @@ def font_file(name: str):
|
||||
async def database_error(request, exc):
|
||||
logging.getLogger(__name__).error('Database request failed', exc_info=exc)
|
||||
return JSONResponse({'success':False,'error':{'code':'database_unavailable','message':'Database service unavailable'}},status_code=503,headers={'Cache-Control':'no-store'})
|
||||
|
||||
# Comparison jobs share the existing queue, access policy and error envelope.
|
||||
from .compare_api import register_comparison_routes
|
||||
register_comparison_routes(app, authorize=authorize, require_api=require_api, error=error)
|
||||
|
||||
@@ -28,3 +28,20 @@ class RenderStatus(BaseModel):
|
||||
class UploadResponse(RenderStatus):
|
||||
status_url: str
|
||||
viewer_token: str | None = Field(default=None,description='Read capability returned once for a private upload')
|
||||
|
||||
class ComparisonStatus(RenderStatus):
|
||||
comparison_id: str
|
||||
url: str
|
||||
status_url: str
|
||||
document_type: Literal['schematic_comparison'] | None = None
|
||||
summary: dict[Literal['added','removed','modified','moved'], int] | None = None
|
||||
total_changed: int | None = None
|
||||
changes: list[dict] | None = None
|
||||
old: dict | None = None
|
||||
new: dict | None = None
|
||||
nets: dict | None = None
|
||||
matching: dict | None = None
|
||||
limitations: list[str] | None = None
|
||||
|
||||
class ComparisonUploadResponse(ComparisonStatus):
|
||||
viewer_token: str | None = Field(default=None, description='Read capability returned once for a private comparison')
|
||||
|
||||
@@ -11,7 +11,7 @@ from .primitives import decode, KINDS, SUPPORTED
|
||||
|
||||
ELECTRICAL=['Input','Bidirectional','Output','Open collector','Passive','Tri-state','Open emitter','Power']
|
||||
|
||||
def parse_schdoc(data: bytes, filename: str, document_id: str) -> Document:
|
||||
def parse_schdoc(data: bytes, filename: str, document_id: str, *, allow_empty=False) -> Document:
|
||||
fmt,streams,main,extra=read_document(data)
|
||||
if not main or 'HEADER' not in main[0].properties:
|
||||
raise ParseError('Missing schematic version header')
|
||||
@@ -144,7 +144,7 @@ def parse_schdoc(data: bytes, filename: str, document_id: str) -> Document:
|
||||
objects.append(SchematicObject(id=r.id,kind='malformed',record_type=-1,source_stream=r.stream,source_index=r.index,
|
||||
visible=False,position=Point(x=0,y=0),bounds=bounds([]),properties=p))
|
||||
if len(primitives)>250000: raise ParseError('Drawable count limit exceeded')
|
||||
if not primitives: raise ParseError('No supported visible geometry found in this schematic')
|
||||
if not primitives and not allow_empty: raise ParseError('No supported visible geometry found in this schematic')
|
||||
by_component={}
|
||||
for shape in primitives:
|
||||
if shape.component_id: by_component.setdefault(shape.component_id,[]).append(shape.bounds)
|
||||
|
||||
@@ -9,7 +9,7 @@ def clean(value): return CONTROL.sub('', str(value))
|
||||
def node(parent, tag, **attrs):
|
||||
return SubElement(parent, tag, {k.replace('_', '-'): clean(v) for k, v in attrs.items()})
|
||||
|
||||
def svg_document(doc: Document) -> bytes:
|
||||
def svg_document(doc: Document, annotations=None, id_prefix="") -> bytes:
|
||||
b = doc.sheet.bounds
|
||||
root = Element('svg', {'xmlns': 'http://www.w3.org/2000/svg', 'viewBox': f'{b.x} {b.y} {b.width} {b.height}',
|
||||
'preserveAspectRatio': 'xMidYMid meet', 'role': 'img', 'aria-label': 'Schematic'})
|
||||
@@ -17,11 +17,14 @@ def svg_document(doc: Document) -> bytes:
|
||||
defs = node(root, 'defs')
|
||||
for i, p in enumerate(doc.primitives):
|
||||
target = root
|
||||
if annotations is not None:
|
||||
annotation = annotations.get(p.component_id or p.object_id)
|
||||
target = node(root, 'g', data_object_id=p.component_id or p.object_id, data_change_id=annotation['id'] if annotation else '')
|
||||
if p.clip:
|
||||
clip = node(defs, 'clipPath', id=f'clip-{i}', clipPathUnits='userSpaceOnUse')
|
||||
clip = node(defs, 'clipPath', id=f'{id_prefix}clip-{i}', clipPathUnits='userSpaceOnUse')
|
||||
c = p.clip
|
||||
node(clip, 'rect', x=c.x, y=c.y, width=c.width, height=c.height)
|
||||
target = node(root, 'g', clip_path=f'url(#clip-{i})')
|
||||
target = node(target, 'g', clip_path=f'url(#{id_prefix}clip-{i})')
|
||||
if p.kind == 'path':
|
||||
n = node(target, 'path', d=p.path, stroke=p.stroke, fill=p.fill,
|
||||
stroke_width=p.width, stroke_linecap='round', stroke_linejoin='round', fill_rule=p.fill_rule)
|
||||
@@ -40,4 +43,14 @@ def svg_document(doc: Document) -> bytes:
|
||||
dominant_baseline=p.baseline, text_decoration='underline' if p.underline else 'none')
|
||||
n.set('{http://www.w3.org/XML/1998/namespace}space', 'preserve')
|
||||
n.text = clean(p.text)
|
||||
if annotations is not None:
|
||||
colors = {'added':'#16854b','removed':'#cf3545','modified':'#b87b09','moved':'#2676c5'}
|
||||
overlay = node(root, 'g', pointer_events='none')
|
||||
for annotation in annotations.values():
|
||||
b = annotation.get('bounds')
|
||||
if not b: continue
|
||||
color = colors[annotation['status']]
|
||||
node(overlay, 'rect', x=b['x']-2, y=b['y']-2, width=max(4,b['width']+4), height=max(4,b['height']+4),
|
||||
fill=color, fill_opacity='.09', stroke=color, stroke_width='1.5', vector_effect='non-scaling-stroke',
|
||||
data_change_id=annotation['id'], data_diff_box='true')
|
||||
return tostring(root, encoding='utf-8', xml_declaration=True)
|
||||
|
||||
@@ -6,7 +6,7 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from .config import settings
|
||||
|
||||
KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.SchDoc|attempt-[12]/(?:data\.json|drawing\.svg))$')
|
||||
KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.SchDoc|(?:old|new)\.SchDoc|comparison\.json|attempt-[12]/(?:data\.json|drawing\.svg|old\.svg|new\.svg))$')
|
||||
|
||||
class Storage:
|
||||
def __init__(self):
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Shared bounded native schematic upload validation."""
|
||||
from fastapi import HTTPException
|
||||
from .config import settings
|
||||
from .parser.record_decoder import ParseError,detect_format
|
||||
|
||||
async def read_schematic(file):
|
||||
try:
|
||||
name=(file.filename or '').replace('\\','/').rsplit('/',1)[-1]
|
||||
name=''.join(c for c in name if c.isprintable())[:255]
|
||||
if not name.lower().endswith('.schdoc'):
|
||||
raise HTTPException(415,{'code':'unsupported_file_type','message':'Upload a .SchDoc file'})
|
||||
body=bytearray()
|
||||
while chunk:=await file.read(1024*1024):
|
||||
body.extend(chunk)
|
||||
if len(body)>settings().max_upload_bytes:
|
||||
raise HTTPException(413,{'code':'upload_too_large','message':'Schematic exceeds configured upload size'})
|
||||
try:detect_format(bytes(body))
|
||||
except ParseError as exc:raise HTTPException(422,{'code':'invalid_schematic','message':str(exc)}) from exc
|
||||
return name,bytes(body)
|
||||
finally:await file.close()
|
||||
@@ -24,15 +24,24 @@ def process(job):
|
||||
with tempfile.TemporaryDirectory(prefix='schdoc-') as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'input.SchDoc'
|
||||
source.write_bytes(storage().get(job['input_key']))
|
||||
comparison = job['input_key'].endswith('/comparison.json')
|
||||
if comparison:
|
||||
manifest = json.loads(storage().get(job['input_key']))
|
||||
source = root / 'comparison.json'
|
||||
source.write_text(json.dumps(manifest), encoding='utf-8')
|
||||
for side in ('old','new'):
|
||||
(root / f'{side}.SchDoc').write_bytes(storage().get(f"{job['id']}/{side}.SchDoc"))
|
||||
else:
|
||||
source.write_bytes(storage().get(job['input_key']))
|
||||
env = {k: v for k, v in os.environ.items() if k in ('PATH', 'LANG', 'LC_ALL', 'PYTHONPATH')}
|
||||
# Drain no untrusted output into logs. Kill/wait is handled by subprocess.run.
|
||||
subprocess.run([sys.executable, '-m', 'app.parse_task', str(source), str(root), job['filename'], job['id']],
|
||||
subprocess.run([sys.executable, '-m', 'app.compare_task' if comparison else 'app.parse_task', str(source), str(root), job['filename'], job['id']],
|
||||
timeout=config.parse_timeout, check=True, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
result = json.loads((root / 'result.json').read_text())
|
||||
if result['ok']:
|
||||
storage().put(prefix + '/data.json', (root / 'data.json').read_bytes(), 'application/json')
|
||||
storage().put(prefix + '/drawing.svg', (root / 'drawing.svg').read_bytes(), 'image/svg+xml')
|
||||
for name in (('old.svg','new.svg') if comparison else ('drawing.svg',)):
|
||||
storage().put(prefix + '/' + name, (root / name).read_bytes(), 'image/svg+xml')
|
||||
values = dict(status='completed', output_prefix=prefix, summary=json.dumps(result['summary']))
|
||||
else:
|
||||
values = dict(status='failed', error_code=result['code'], error_message=result['message'])
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from xml.etree.ElementTree import fromstring
|
||||
import pytest
|
||||
from sqlalchemy import update
|
||||
from app.diff import compare_documents
|
||||
from app.parser.schdoc_parser import parse_schdoc
|
||||
from app.render import svg_document
|
||||
from app.db import Job,claim,sessions
|
||||
from app.storage import storage
|
||||
from app.worker import process
|
||||
from test_platform import client,KEY
|
||||
|
||||
|
||||
def parse(raw,name='test.SchDoc'):return parse_schdoc(raw,name,'test')
|
||||
def compare(a,b):return compare_documents(parse(a),parse(b))
|
||||
def create(client,old,new,**fields):
|
||||
return client.post('/api/compare',headers={'X-API-Key':KEY},
|
||||
files={'old_file':('old.SchDoc',old),'new_file':('new.SchDoc',new)},data=fields)
|
||||
|
||||
|
||||
def test_identical_and_record_reordering(ascii_schematic):
|
||||
assert compare(ascii_schematic,ascii_schematic)['total_changed']==0
|
||||
lines=ascii_schematic.decode().splitlines();records=lines[1:]
|
||||
order=list(reversed(range(len(records))));mapping={old:new for new,old in enumerate(order)}
|
||||
shuffled=[re.sub(r'(?i)OwnerIndex=(\d+)',lambda m:f'OwnerIndex={mapping[int(m[1])]}',records[i]) for i in order]
|
||||
result=compare(ascii_schematic,(lines[0]+'\n'+'\n'.join(shuffled)).encode())
|
||||
assert result['total_changed']==0,result['changes']
|
||||
|
||||
|
||||
def test_component_properties_and_renumbering_with_native_identity(ascii_schematic):
|
||||
old=ascii_schematic.replace(b'LibReference=R_Symbol',b'UniqueId=COMPONENT01|LibReference=R_Symbol')
|
||||
new=old.replace(b'Text=R1',b'Text=R9').replace(b'Text=4K7',b'Text=10K')
|
||||
result=compare(old,new)
|
||||
assert result['summary']=={'added':0,'removed':0,'modified':1,'moved':0}
|
||||
change=result['changes'][0]
|
||||
assert change['kind']=='component' and change['match']=='unique_id'
|
||||
assert {'designator','value','parameters'}<={f['property'] for f in change['fields']}
|
||||
|
||||
|
||||
def move_component(raw,dx=20,dy=10):
|
||||
lines=[]
|
||||
for line in raw.decode().splitlines():
|
||||
if '|RECORD=1|' in line or '|OwnerIndex=1|' in line:
|
||||
line=re.sub(r'(Location|Corner)\.([XY])=(-?\d+)',lambda m:f'{m[1]}.{m[2]}={int(m[3])+(dx if m[2]=="X" else dy)}',line)
|
||||
lines.append(line)
|
||||
return ('\n'.join(lines)+'\n').encode()
|
||||
|
||||
|
||||
def test_movement_without_inventing_internal_symbol_changes(ascii_schematic):
|
||||
result=compare(ascii_schematic,move_component(ascii_schematic))
|
||||
assert result['summary']=={'added':0,'removed':0,'modified':0,'moved':1},result['changes']
|
||||
assert result['changes'][0]['movement']=={'dx':20,'dy':-10}
|
||||
assert not result['changes'][0]['fields']
|
||||
both=compare(ascii_schematic,move_component(ascii_schematic).replace(b'Text=4K7',b'Text=10K'))
|
||||
assert both['changes'][0]['moved'] and both['changes'][0]['status']=='modified'
|
||||
|
||||
|
||||
def test_added_removed_component_and_wire(ascii_schematic):
|
||||
added=ascii_schematic+b'''|RECORD=1|LibReference=C|UniqueId=CAP01|Location.X=300|Location.Y=400|CurrentPartId=1
|
||||
|RECORD=13|OwnerIndex=9|Location.X=300|Location.Y=400|Corner.X=310|Corner.Y=410
|
||||
|RECORD=34|OwnerIndex=9|Text=C2|Location.X=300|Location.Y=420
|
||||
|RECORD=27|UniqueId=WIRE_NEW|LocationCount=2|X1=300|Y1=400|X2=350|Y2=400
|
||||
'''
|
||||
result=compare(ascii_schematic,added)
|
||||
assert result['summary']['added']==2 and result['total_changed']==2
|
||||
assert {c['kind'] for c in result['changes']}=={'component','wire'}
|
||||
assert compare(added,ascii_schematic)['summary']['removed']==2
|
||||
|
||||
|
||||
def test_nets_report_labels_not_compiled_connectivity(ascii_schematic):
|
||||
result=compare(ascii_schematic,ascii_schematic.replace(b'Text=VCC',b'Text=VBAT'))
|
||||
assert result['summary']['modified']==1
|
||||
assert result['nets']=={'added':['VBAT'],'removed':['VCC'],'changed':[]}
|
||||
assert result['changes'][0]['kind']=='net_label'
|
||||
assert any('compiled' in message for message in result['limitations'])
|
||||
|
||||
|
||||
def test_wire_reversal_is_not_an_electrical_or_visual_change(ascii_schematic):
|
||||
new=ascii_schematic.replace(b'X1=100|Y1=230|X2=150|Y2=230',b'X1=150|Y1=230|X2=100|Y2=230')
|
||||
assert compare(ascii_schematic,new)['total_changed']==0
|
||||
|
||||
|
||||
def test_duplicates_are_not_arbitrary_matches(ascii_schematic):
|
||||
extra=b'|RECORD=27|LocationCount=2|X1=10|Y1=10|X2=20|Y2=10\n'
|
||||
assert compare(ascii_schematic+extra*2,ascii_schematic+extra*2)['total_changed']==0
|
||||
result=compare(ascii_schematic+extra*2,ascii_schematic+extra)
|
||||
assert result['summary']['removed']==1 and result['total_changed']==1
|
||||
|
||||
|
||||
def test_report_does_not_mutate_documents_and_embedded_image_changes(ascii_schematic):
|
||||
from app.models.schematic import Primitive,Bounds,Point
|
||||
old=parse(ascii_schematic);new=old.model_copy(deep=True)
|
||||
before=old.model_dump_json();compare_documents(old,new);assert old.model_dump_json()==before
|
||||
# Existing parsed image record with a changed binary raster, same filename.
|
||||
obj=new.objects[-1].model_copy(deep=True);obj.id='image';obj.kind='image';obj.record_type=30
|
||||
obj.properties={'RECORD':'30','FILENAME':'logo.bmp','LOCATION.X':'0','LOCATION.Y':'0'};obj.primitive_ids=['image:p'];obj.component_id=None
|
||||
old.objects.append(obj);new.objects.append(obj.model_copy(deep=True))
|
||||
p=Primitive(id='image:p',kind='image',object_id='image',bounds=Bounds(x=0,y=0,width=10,height=10),image_data='data:image/png;base64,OLD')
|
||||
old.primitives.append(p);new.primitives.append(p.model_copy(update={'image_data':'data:image/png;base64,NEW'}))
|
||||
assert compare_documents(old,new)['summary']['modified']==1
|
||||
|
||||
|
||||
def test_highlight_svg_preserves_geometry_and_distinct_clip_ids(ascii_schematic):
|
||||
raw=ascii_schematic+b'|RECORD=28|Location.X=1|Corner.X=20|Corner.Y=20|ClipToRect=T|Text=note\n'
|
||||
doc=parse(raw);item=doc.objects[-1]
|
||||
annotations={item.id:{'id':'change-0','status':'modified','bounds':item.bounds.model_dump()}}
|
||||
svg=svg_document(doc,annotations,id_prefix='old-');tree=fromstring(svg)
|
||||
assert b'data-diff-box="true"' in svg and b'data-change-id="change-0"' in svg
|
||||
assert b'old-clip-' in svg and b'url(#old-clip-' in svg
|
||||
assert b'M100,-230 L150,-230' in svg
|
||||
assert not list(tree.iter('{http://www.w3.org/2000/svg}script'))
|
||||
|
||||
|
||||
def test_comparison_api_worker_private_results_and_cleanup(client,ascii_schematic):
|
||||
response=create(client,ascii_schematic,ascii_schematic.replace(b'Text=4K7',b'Text=10K'))
|
||||
assert response.status_code==202,response.text
|
||||
created=response.json();identifier=created['comparison_id'];headers={'X-Viewer-Token':created['viewer_token']}
|
||||
assert '#token=' in created['url'] and created['status']=='queued'
|
||||
base='/api/v1/comparisons/'+identifier
|
||||
assert client.get(base).status_code==404
|
||||
assert client.get(base,headers={'X-Viewer-Token':'bad'}).status_code==404
|
||||
assert client.get(base+'/svg/old',headers=headers).status_code==409
|
||||
job=claim();process(job)
|
||||
response=client.get(base,headers=headers);result=response.json()
|
||||
assert result['status']=='completed',result
|
||||
assert result['summary']['modified']==1
|
||||
assert client.get('/api/compare/'+identifier,headers=headers).json()['changes']==result['changes']
|
||||
for side in ('old','new'):
|
||||
r=client.get(base+'/svg/'+side,headers=headers)
|
||||
assert r.status_code==200 and b'data-diff-box' in r.content
|
||||
assert client.get('/compare/'+identifier).status_code==200
|
||||
assert client.get('/compare').status_code==200
|
||||
for asset in ['compare.js','compare.css','interactions.js','compare-upload.js']:
|
||||
assert client.get('/assets/'+asset).status_code==200
|
||||
with sessions().begin() as db:db.execute(update(Job).where(Job.id==identifier).values(expires_at=time.time()-1))
|
||||
assert client.get(base,headers=headers).status_code==410
|
||||
from app.cleanup import cleanup
|
||||
assert cleanup()==1
|
||||
with pytest.raises(FileNotFoundError):storage().get(identifier+'/old.SchDoc')
|
||||
|
||||
|
||||
def test_comparison_validation_auth_and_queue_limit(client,ascii_schematic,monkeypatch):
|
||||
assert client.post('/api/compare').status_code==401
|
||||
assert client.post('/api/v1/comparisons').status_code==401
|
||||
assert create(client,ascii_schematic,b'garbage').status_code==422
|
||||
assert create(client,ascii_schematic,ascii_schematic,expires_in=999999999).status_code==422
|
||||
assert create(client,b'x'*(1048576+1),ascii_schematic).status_code==413
|
||||
monkeypatch.setenv('MAX_PENDING_JOBS','1')
|
||||
from app.config import settings
|
||||
settings.cache_clear()
|
||||
assert create(client,ascii_schematic,ascii_schematic).status_code==202
|
||||
assert create(client,ascii_schematic,ascii_schematic).status_code==503
|
||||
|
||||
|
||||
def test_corrupt_second_file_fails_durably_and_unlisted(client,ascii_schematic):
|
||||
response=create(client,ascii_schematic,b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'+b'\0'*504,access='unlisted')
|
||||
assert response.status_code==202
|
||||
process(claim())
|
||||
result=client.get('/api/compare/'+response.json()['id']).json()
|
||||
assert result['status']=='failed' and result['error']['code']=='invalid_schematic'
|
||||
|
||||
|
||||
def test_comparison_cross_origin_preflight_and_creation(client,ascii_schematic):
|
||||
origin='https://git.sadentel.net'
|
||||
r=client.options('/api/compare',headers={'Origin':origin,'Access-Control-Request-Method':'POST','Access-Control-Request-Headers':'x-api-key'})
|
||||
assert r.status_code==200 and r.headers['access-control-allow-origin']==origin
|
||||
r=client.post('/api/compare',headers={'Origin':origin})
|
||||
assert r.status_code==401 and r.headers['access-control-allow-origin']==origin
|
||||
|
||||
|
||||
def test_partial_storage_failure_leaves_no_comparison_job(client,ascii_schematic,monkeypatch):
|
||||
original=storage().put;calls=[]
|
||||
def failing(key,body,*args,**kw):
|
||||
calls.append(key)
|
||||
if len(calls)==2:raise RuntimeError('S3 unavailable')
|
||||
return original(key,body,*args,**kw)
|
||||
monkeypatch.setattr(storage(),'put',failing)
|
||||
assert create(client,ascii_schematic,ascii_schematic).status_code==503
|
||||
assert claim() is None
|
||||
assert not storage().path(calls[0]).exists()
|
||||
|
||||
|
||||
def test_native_ole_comparison_preserves_original_geometry(client):
|
||||
fixture=Path(__file__).resolve().parents[2]/'.runtime/fixtures/Channel.SchDoc'
|
||||
if not fixture.is_file():pytest.skip('Fetch the native fixture')
|
||||
response=create(client,fixture.read_bytes(),fixture.read_bytes());assert response.status_code==202
|
||||
process(claim())
|
||||
identifier=response.json()['id'];headers={'X-Viewer-Token':response.json()['viewer_token']}
|
||||
result=client.get('/api/v1/comparisons/'+identifier,headers=headers).json()
|
||||
assert result['status']=='completed',result
|
||||
assert result['total_changed']==0
|
||||
assert result['old']['sha256']==result['new']['sha256']
|
||||
assert b'data-object-id' in client.get(f'/api/v1/comparisons/{identifier}/svg/old',headers=headers).content
|
||||
|
||||
|
||||
def test_comparison_request_body_is_bounded(client):
|
||||
response=client.post('/api/compare',content=b'x'*(4*1048576),
|
||||
headers={'X-API-Key':KEY,'Content-Type':'multipart/form-data; boundary=foo'})
|
||||
assert response.status_code==413
|
||||
|
||||
|
||||
def test_comparison_with_valid_empty_sheet_keeps_single_parser_behavior(client,ascii_schematic):
|
||||
from app.parser.record_decoder import ParseError
|
||||
empty=b'|HEADER=Protel for Windows - Schematic Capture Ascii File Version 5.0\n|RECORD=31|FontIdCount=1|Size1=10|FontName1=Arial\n'
|
||||
with pytest.raises(ParseError,match='No supported visible geometry'):parse(empty)
|
||||
response=create(client,ascii_schematic,empty);assert response.status_code==202
|
||||
process(claim());headers={'X-Viewer-Token':response.json()['viewer_token']}
|
||||
result=client.get('/api/compare/'+response.json()['id'],headers=headers).json()
|
||||
assert result['status']=='completed',result
|
||||
assert result['summary']['removed']==3
|
||||
assert result['summary']['added']==0
|
||||
svg=client.get('/api/v1/comparisons/'+response.json()['id']+'/svg/new',headers=headers)
|
||||
assert svg.status_code==200 and b'<rect' in svg.content
|
||||
|
||||
|
||||
def test_comparison_ids_do_not_become_single_schematic_resources(client,ascii_schematic):
|
||||
response=create(client,ascii_schematic,ascii_schematic)
|
||||
identifier=response.json()['id'];headers={'X-Viewer-Token':response.json()['viewer_token']}
|
||||
for endpoint in ('status','data','svg'):
|
||||
assert client.get(f'/api/v1/schematics/{identifier}/{endpoint}',headers=headers).status_code==404
|
||||
from test_platform import upload
|
||||
single=upload(client,ascii_schematic).json()
|
||||
assert client.get('/api/compare/'+single['id'],headers={'X-Viewer-Token':single['viewer_token']}).status_code==404
|
||||
@@ -41,3 +41,7 @@ Error code examples:
|
||||
| 503 | queue_full / storage_unavailable / database_unavailable |
|
||||
|
||||
A worker crash is retried after its lease expires, at most twice total. A parse failure is terminal for that upload. No automatic retry of side-effecting API requests is performed. Data and SVG reads are immutable after a successful attempt; access/expiry remain server-authoritative on every read. HTTP caches are disabled to protect private drawings.
|
||||
|
||||
## Schematic comparisons
|
||||
|
||||
`POST /api/v1/comparisons` (alias `/api/compare`) accepts `old_file` and `new_file`, with the same API key, private tokens, expiry, durable queue and error conventions. `GET /api/v1/comparisons/{id}` (alias `/api/compare/{id}`) returns status and the completed structural diff. `/compare/{id}` opens the side-by-side workspace. See [the comparison contract](SCHEMATIC_DIFF.md).
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Schematic Diff Viewer
|
||||
|
||||
The comparison feature reuses native SchDoc parsing, the original vector geometry, the SVG serializer, the existing PostgreSQL/SQLite durable job queue, bounded parser subprocesses, local/S3 storage, API-key authentication and expiring viewer capabilities. It adds no runtime or frontend packages and no database columns/tables/migration.
|
||||
|
||||
Open `/compare` to select an original and modified file, or follow the comparison link on the main upload page. After processing, `/compare/{id}#token=…` shows the original on the left and modified document on the right. Original schematic primitives keep their colors and geometry; optional boxes annotate changes. Added objects are green, removed red, modified amber and moved blue. A changes list provides property details, previous/next navigation and click-to-focus. Zoom/pan/pinch, synchronized world coordinates, fit, category filtering, changes-only mode and highlight toggling are available. Both drawings have unique SVG clip identifiers to avoid collisions in the same DOM.
|
||||
|
||||
The single-file viewer and its fullscreen interface remain unchanged. The new comparison page is a separate workspace. Existing iframe policy remains limited to `/view/*`; `/compare/*` is a standalone page and is not additionally granted cross-origin framing permission by this feature.
|
||||
|
||||
## API
|
||||
|
||||
Primary versioned endpoint: `POST /api/v1/comparisons`. `POST /api/compare` is an alias for the same implementation. Upload authentication is the existing `X-API-Key` header.
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8000/api/compare \
|
||||
-H "X-API-Key: $API_KEY" \
|
||||
-F 'old_file=@original.SchDoc' \
|
||||
-F 'new_file=@modified.SchDoc' \
|
||||
-F 'access=private' \
|
||||
-F 'expires_in=3600'
|
||||
```
|
||||
|
||||
Response: HTTP **202**, matching the existing asynchronous rendering convention:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"id": "96a2db531ed846e79be507eec36e00ba4c09a142d67ab35d",
|
||||
"comparison_id": "96a2db531ed846e79be507eec36e00ba4c09a142d67ab35d",
|
||||
"status": "queued",
|
||||
"url": "http://127.0.0.1:8000/compare/96a2db531ed846e79be507eec36e00ba4c09a142d67ab35d#token=READ_TOKEN",
|
||||
"viewer_url": "http://127.0.0.1:8000/compare/96a2db531ed846e79be507eec36e00ba4c09a142d67ab35d#token=READ_TOKEN",
|
||||
"viewer_token": "READ_TOKEN",
|
||||
"status_url": "http://127.0.0.1:8000/api/v1/comparisons/96a2db531ed846e79be507eec36e00ba4c09a142d67ab35d",
|
||||
"access": "private",
|
||||
"expires_at": 1794040000
|
||||
}
|
||||
```
|
||||
|
||||
Retain the initial URL including its token fragment. Opening it immediately is supported: the workspace polls until processing completes. `summary` is available only when the worker finishes; a queued upload cannot truthfully report change counts yet.
|
||||
|
||||
Poll `GET /api/v1/comparisons/{id}` (alias `GET /api/compare/{id}`) with `X-Viewer-Token: READ_TOKEN` or the existing service API key. The states and JSON error envelope follow the rendering API: queued, processing, completed, failed. A malformed file may pass initial envelope identification and fail later in the native parser. Missing/bad private tokens receive 404; expired authorized links receive 410 until cleanup. `access=unlisted` is an explicit alternative with the same unpredictable identifiers as the single viewer.
|
||||
|
||||
Completed responses include:
|
||||
|
||||
- `summary`: mutually exclusive primary counts `added`, `removed`, `modified`, `moved`; their sum equals `total_changed`.
|
||||
- `changes`: status, kind, label, old/new source object IDs, old/new bounds and primitive IDs, matching method, property differences and movement delta of the stored anchor in document units. When geometry also changes, an anchor delta does not assert rigid translation of the entire object. An object that changes properties and moves has primary status `modified` plus `moved: true` and `movement`; it is not counted twice.
|
||||
- `old` and `new`: filenames, source SHA-256, original sheet/content bounds and parser diagnostics. Initial camera fit uses combined content bounds, while keeping both panes in the same native coordinate system.
|
||||
- `nets`: added/removed observed net names, and names whose label/port anchor sets changed. A rename is also a modified label with old/new TEXT/NAME in its field changes.
|
||||
- `matching`: counts of paired/unpaired objects and ambiguous remaining signature groups.
|
||||
- `limitations`: explicit scope and uncertainty notes.
|
||||
|
||||
`GET /api/v1/comparisons/{id}/svg/old` and `/svg/new` return annotated native SVGs with the same read permission checks. Pending SVGs return 409; failed SVGs 422. Existing single-schematic endpoints remain unchanged; comparison clients should use the comparison endpoints and initial comparison URL.
|
||||
|
||||
## Structural matching and interpretation
|
||||
|
||||
Objects owned by a component are aggregated into one component change rather than counting every pin, symbol segment and designator as independent additions. Component-owned geometry is compared relative to its original placement, so translating a component and its children does not invent internal symbol changes. Properties, parameters, values, designators, rotations, mirrored state, part selection and symbol-record signatures are compared. Embedded raster changes are detected by payload hashes even when filenames match. Unowned wires, labels, ports, junctions, shapes and other supported/source objects are compared separately; sheet properties are compared as sheet settings.
|
||||
|
||||
Matching stages are: unique native `UNIQUEID` within the object kind, unique component designator/unit, exact structural signature and position, unique translation-independent structure, then unique kind/position. Only keys unique on both sides are paired at each heuristic stage. Identical repeated objects are compared as multisets. Physical FileHeader/Additional record indexes never serve as persistent cross-file identity, and changes in record order do not create changes by themselves. Ownership/index bookkeeping is ignored. Fractional coordinates are preserved. Wire vertex reversal is normalized as the same undirected wire geometry.
|
||||
|
||||
Fallback matching is necessarily heuristic when stable IDs are absent or regenerated: an object replaced at the same position may look like a modification. Ambiguous objects remain added/removed instead of receiving arbitrary nearest-neighbor matches. Every pair records its matching method. Changes to unsupported objects can be identified from retained properties, but cannot be faithfully highlighted if the parser has no drawable geometry. Such changes remain in the list with no fabricated bounding box.
|
||||
|
||||
This is **not an Altium compiled netlist comparison**. Wire reroutes, pin positions, net labels and connection anchors can reveal graphic changes, but do not prove an electrical connection was added/removed or changed. Bus expansion, harness routing, repeated channels and cross-sheet/project-level connectivity are not resolved. Do not use these graphics as proof of electrical equivalence. Parser/font/format fidelity limitations from [compatibility](COMPATIBILITY.md) still apply independently to both sides. No pixel-difference engine or generated substitute symbol library is involved.
|
||||
|
||||
## Queue, storage and deployment
|
||||
|
||||
A comparison occupies one existing `Job` row and one queue slot. Its application-generated input key is `{id}/comparison.json`, an immutable manifest with the two sanitized filenames; the actual sources are `{id}/old.SchDoc` and `{id}/new.SchDoc`. The worker uses that key type to dispatch `app.compare_task` through the same process, resource limits, retry lease and stale-attempt fencing. Successful attempts write `data.json`, `old.svg` and `new.svg` into the existing attempt-scoped storage prefix. No runtime data is embedded in the frontend. Expiry cleanup removes both sources, the manifest and attempt artifacts. Local and S3 paths use the same strict key validation. Multi-file upload failures clean up already-written inputs before a job is admitted.
|
||||
|
||||
Comparison parsing explicitly permits valid empty sheets so removing all objects can be compared. The single-file parser retains its existing empty-geometry rejection by default. Each file retains `MAX_UPLOAD_BYTES` (default 32 MiB). Only the two comparison POST paths get a combined multipart limit of `2 * MAX_UPLOAD_BYTES + 1 MiB`; single-file requests keep their original bound. The Caddy example keeps its existing general request limit and adds 65 MiB for the two comparison paths. Adjust external CDN/proxy upload caps correspondingly when deploying larger comparisons. This changes request size handling, not CSP/CORS policy. The same total CPU/memory/wall-clock limits apply per comparison job, so extremely complex pairs may hit resource limits even if each file renders separately.
|
||||
|
||||
Restart API and workers after updating. With Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build api worker
|
||||
```
|
||||
|
||||
If using the production Caddy overlay, reload/recreate its proxy to pick up the route-specific multipart limit. A schema migration is not needed.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
SCHDOC_SAMPLE='/path/to/real.SchDoc' ./scripts/check.sh
|
||||
API_KEY="$API_KEY" .venv/bin/python scripts/compare-smoke.py original.SchDoc modified.SchDoc
|
||||
```
|
||||
|
||||
Tests cover record reordering, component UID matching/renumbering, values, pure movement, combined movement/property edits, additions/removals, reversed wire vertices, duplicate objects, labels/net-name scope, embedded raster replacement, nonmutation, original SVG geometry, clip-ID separation, upload/authentication/CORS, per-file/combined limits, partial storage failures, a real OLE same-file comparison, private result/SVG access, failed processing and expiry cleanup. Frontend camera adapter tests cover pane offsets, synchronized world cameras, pinch, change focus and detached listeners. Synthetic unit circuits never appear as application data.
|
||||
|
||||
The actual local `Sheet1-before.SchDoc` / `Sheet1-after.SchDoc` pair (205/206 source records) produced 1 moved component, 2 modified wires and 1 added junction. The 1,942-record CPU sheet compared with itself produced zero changes. These are structural integration checks, not a trusted Altium visual comparison or browser UI acceptance test. No proprietary samples are redistributed. Browser interaction and visual validation require a connected browser; absence of browser QA must be reported rather than replaced by camera unit tests.
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Compare Altium schematics</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/compare-upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · STRUCTURAL DIFF</p><h1>Compare schematics.</h1><p>Select the original and modified SchDoc files.</p><form id="compare-upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="old-file">Original schematic</label><input id="old-file" type="file" accept=".SchDoc" required><label for="new-file">Modified schematic</label><input id="new-file" type="file" accept=".SchDoc" required><button type="submit">Compare schematics <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Comparison uses native objects and saved geometry. Connection graphics are compared without a compiled electrical netlist.</p><a href="/">Single schematic viewer</a></main></body></html>
|
||||
@@ -0,0 +1,12 @@
|
||||
const form=document.querySelector('#compare-upload'),result=document.querySelector('#result');
|
||||
form.addEventListener('submit',async e=>{
|
||||
e.preventDefault();const button=form.querySelector('button');button.disabled=true;result.textContent='Uploading both schematics…';
|
||||
const headers={},key=document.querySelector('#key').value;
|
||||
if(key)headers['X-API-Key']=key;
|
||||
const body=new FormData();body.append('old_file',document.querySelector('#old-file').files[0]);body.append('new_file',document.querySelector('#new-file').files[0]);
|
||||
try {
|
||||
const response=await fetch('/api/v1/comparisons',{method:'POST',headers,body});const data=await response.json();
|
||||
if(!response.ok)throw new Error(data.error?.message||'Comparison upload failed');
|
||||
result.textContent='Queued. ';const link=document.createElement('a');link.href=data.url;link.textContent='Open comparison';result.append(link);
|
||||
}catch(error){result.textContent=error.message;}finally{button.disabled=false;}
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
body { display: flex; flex-direction: column; font-family: system-ui,sans-serif; color: #233431; background: #f4f6f5; }
|
||||
* { box-sizing: border-box; }
|
||||
header { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; padding: 12px 16px; border-bottom: 1px solid #d6dfda; }
|
||||
header > a { color: #244d49; font-size: 15px; font-weight: 650; text-decoration: none; }
|
||||
#summary, .controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; font-size: 12px; }
|
||||
.controls { margin-left: auto; }
|
||||
label { display: inline-flex; gap: 4px; align-items: center; }
|
||||
button, select { border: 1px solid #cbd5ce; border-radius: 4px; padding: 5px 9px; background: white; color: inherit; font: inherit; cursor: pointer; }
|
||||
button:focus-visible, select:focus-visible, .surface:focus-visible { outline: 2px solid #2676c5; outline-offset: -2px; }
|
||||
#scope { margin: 0; padding: 7px 16px; font-size: 11px; color: #60716b; border-bottom: 1px solid #d6dfda; }
|
||||
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) 270px; }
|
||||
.pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-right: 1px solid #d6dfda; }
|
||||
h2 { margin: 0; padding: 9px 12px; font-size: 12px; font-weight: 650; background: #edf1ee; display: flex; gap: 10px; }
|
||||
h2 span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: normal; color: #61746b; }
|
||||
.surface { flex: 1; min-height: 0; width: 100%; overflow: hidden; background: white; touch-action: none; user-select: none; cursor: grab; }
|
||||
.surface:active { cursor: grabbing; }
|
||||
.surface > svg { display: block; width: 100%; height: 100%; }
|
||||
aside { display: flex; flex-direction: column; min-height: 0; overflow: hidden; background: #fafcfb; }
|
||||
.change-controls { display: flex; align-items: center; gap: 6px; padding: 10px; font-size: 12px; }
|
||||
.change-controls label { margin-right: auto; }
|
||||
#changes { flex: 1; min-height: 0; overflow: auto; padding: 0 8px; }
|
||||
#changes button { display: flex; text-align: left; align-items: center; gap: 8px; width: 100%; margin-bottom: 5px; border-left: 3px solid currentColor; font-size: 12px; padding: 10px; }
|
||||
#changes button small { margin-left: auto; color: #64756d; font-size: 10px; }
|
||||
#changes button[aria-current="true"] { background: #e5eee9; box-shadow: inset 0 0 0 1px #8daba0; }
|
||||
.added { color: #16854b; }.removed { color: #cf3545; }.modified { color: #b87b09; }.moved { color: #2676c5; }
|
||||
#details { max-height: 35%; overflow: auto; margin: 0; padding: 10px; white-space: pre-wrap; overflow-wrap: anywhere; border-top: 1px solid #d6dfda; font-size: 11px; }
|
||||
#details:empty { display: none; }
|
||||
[data-diff-hidden] { display: none; }
|
||||
.no-highlights [data-diff-box] { display: none; }
|
||||
[data-diff-box][data-selected] { stroke-width: 3; fill-opacity: .18; }
|
||||
@media (max-width: 900px) { .workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr); grid-template-rows: minmax(0,1fr) 180px; } aside { grid-column: 1/-1; } #details { max-height: 40%; } }
|
||||
@media (max-width: 550px) { .workspace { grid-template-columns: 1fr; grid-template-rows: minmax(0,1fr) minmax(0,1fr) 140px; } aside { grid-column: auto; } header { gap: 8px; }.controls { margin-left: 0; } }
|
||||
|
||||
#changes button[hidden] { display: none; }
|
||||
@@ -0,0 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic comparison</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><link rel="stylesheet" href="/assets/compare.css"><script type="module" src="/assets/compare.js"></script></head><body>
|
||||
<header><a href="/compare">Schematic Diff</a><div id="summary" aria-live="polite"></div><div class="controls"><label><input id="sync" type="checkbox" checked> Sync views</label><label><input id="highlights" type="checkbox" checked> Highlights</label><label><input id="only-changes" type="checkbox"> Changes only</label><button id="fit" type="button">Fit</button></div></header>
|
||||
<p id="scope">Native object comparison · Connection graphics, not a compiled electrical netlist.</p>
|
||||
<main class="workspace"><section class="pane"><h2>Original <span id="old-name"></span></h2><div id="old-surface" class="surface" tabindex="0" aria-label="Original schematic"></div></section><section class="pane"><h2>Modified <span id="new-name"></span></h2><div id="new-surface" class="surface" tabindex="0" aria-label="Modified schematic"></div></section><aside><div class="change-controls"><label for="filter">Changes</label><select id="filter"><option value="all">All</option><option value="added">Added</option><option value="removed">Removed</option><option value="modified">Modified</option><option value="moved">Moved</option></select><button id="previous" aria-label="Previous change">↑</button><button id="next" aria-label="Next change">↓</button></div><nav id="changes" aria-label="Schematic changes"></nav><pre id="details" aria-live="polite"></pre></aside></main>
|
||||
<output id="state" class="loading" role="status">Loading comparison…</output></body></html>
|
||||
@@ -0,0 +1,87 @@
|
||||
import { bindCamera } from './interactions.js';
|
||||
const state=document.querySelector('#state'),id=location.pathname.split('/').pop();
|
||||
const token=new URLSearchParams(location.hash.slice(1)).get('token')||sessionStorage.getItem(`comparison:${id}`);
|
||||
if(token)sessionStorage.setItem(`comparison:${id}`,token);
|
||||
if(location.hash)history.replaceState(null,'',location.pathname);
|
||||
const headers=token?{'X-Viewer-Token':token}:{},base=`/api/v1/comparisons/${id}`;
|
||||
const controller=new AbortController(),views=[];
|
||||
addEventListener('pagehide',()=>{controller.abort();for(const view of views)view.destroy();},{once:true});
|
||||
async function request(path='',text=false){
|
||||
const response=await fetch(base+path,{headers,signal:controller.signal,cache:'no-store'});
|
||||
if(!response.ok){const body=await response.json();throw new Error(body.error?.message||'Comparison unavailable');}
|
||||
return text?response.text():response.json();
|
||||
}
|
||||
function union(boxes){
|
||||
const x=Math.min(...boxes.map(b=>b.x)),y=Math.min(...boxes.map(b=>b.y));
|
||||
return {x,y,width:Math.max(...boxes.map(b=>b.x+b.width))-x,height:Math.max(...boxes.map(b=>b.y+b.height))-y};
|
||||
}
|
||||
async function start(){
|
||||
let result;
|
||||
for(;;){
|
||||
result=await request();if(result.status==='completed')break;
|
||||
if(result.status==='failed')throw new Error(result.error?.message||'Comparison failed');
|
||||
await new Promise(resolve=>setTimeout(resolve,1000));
|
||||
}
|
||||
const texts=await Promise.all(['old','new'].map(side=>request('/svg/'+side,true)));
|
||||
const svgs=texts.map(text=>{
|
||||
const parsed=new DOMParser().parseFromString(text,'image/svg+xml');
|
||||
if(parsed.querySelector('parsererror')||parsed.documentElement.localName!=='svg')throw new Error('Invalid rendering artifact');
|
||||
return document.importNode(parsed.documentElement,true);
|
||||
});
|
||||
const content=[result.old.bounds,result.new.bounds].filter(b=>b&&b.width>0&&b.height>0);
|
||||
const bounds=union(content.length?content:[result.old.sheet,result.new.sheet]);
|
||||
for(const [index,side] of ['old','new'].entries()){
|
||||
document.querySelector(`#${side}-name`).textContent=result[side].filename;
|
||||
const surface=document.querySelector(`#${side}-surface`);surface.append(svgs[index]);
|
||||
surface.style.backgroundColor=svgs[index].querySelector('rect')?.getAttribute('fill')||'#ffffff';
|
||||
views.push(bindCamera(surface,svgs[index],bounds,source=>{
|
||||
if(document.querySelector('#sync').checked)for(const other of views)if(other!==source)other.apply(source.pose());
|
||||
}));
|
||||
}
|
||||
await document.fonts.ready;
|
||||
state.classList.remove('loading');state.textContent='';
|
||||
const changes=result.changes,byId=new Map(changes.map(c=>[c.id,c])),rows=new Map(),nodes=svgs.flatMap(svg=>[...svg.querySelectorAll('[data-change-id]')]);
|
||||
const list=document.querySelector('#changes'),filter=document.querySelector('#filter');
|
||||
let selected=null;
|
||||
for(const [status,count]of Object.entries(result.summary)){
|
||||
const badge=document.createElement('span');badge.className=status;badge.textContent=`${count} ${status}`;document.querySelector('#summary').append(badge);
|
||||
}
|
||||
if(!changes.length)list.textContent='No structural changes detected.';
|
||||
function visibleChanges(){return changes.filter(c=>filter.value==='all'||c.status===filter.value);}
|
||||
function select(change){
|
||||
selected=change.id;
|
||||
for(const [id,row]of rows)row.setAttribute('aria-current',String(id===selected));
|
||||
for(const node of nodes){if(node.dataset.changeId===selected)node.setAttribute('data-selected','');else node.removeAttribute('data-selected');}
|
||||
const boxes=[change.old_bounds,change.new_bounds].filter(Boolean);
|
||||
if(boxes.length)for(const view of views)view.focus(union(boxes));
|
||||
const details={kind:change.kind,match:change.match||'unmatched'};
|
||||
if(change.moved)details.movement=change.movement;
|
||||
if(change.fields.length)details.fields=change.fields;
|
||||
document.querySelector('#details').textContent=JSON.stringify(details,null,2);
|
||||
rows.get(selected)?.scrollIntoView({block:'nearest'});
|
||||
}
|
||||
for(const change of changes){
|
||||
const button=document.createElement('button');button.type='button';button.className=change.status;
|
||||
const label=document.createElement('span');label.textContent=change.label;
|
||||
const kind=document.createElement('small');kind.textContent=change.status+(change.moved&&change.status==='modified'?', moved':'')+' · '+change.kind;
|
||||
button.append(label,kind);button.addEventListener('click',()=>select(change));list.append(button);rows.set(change.id,button);
|
||||
}
|
||||
function applyFilters(){
|
||||
for(const node of nodes){
|
||||
const change=byId.get(node.dataset.changeId);
|
||||
const hidden=change?(filter.value!=='all'&&change.status!==filter.value):(filter.value!=='all'||document.querySelector('#only-changes').checked);
|
||||
node.toggleAttribute('data-diff-hidden',hidden);
|
||||
}
|
||||
for(const change of changes)rows.get(change.id).hidden=filter.value!=='all'&&change.status!==filter.value;
|
||||
document.body.classList.toggle('no-highlights',!document.querySelector('#highlights').checked);
|
||||
}
|
||||
for(const element of [filter,document.querySelector('#only-changes'),document.querySelector('#highlights')])element.addEventListener('change',applyFilters);
|
||||
document.querySelector('#fit').addEventListener('click',()=>{for(const view of views)view.fit();});
|
||||
document.querySelector('#sync').addEventListener('change',()=>{if(document.querySelector('#sync').checked&&views[0])views[1].apply(views[0].pose());});
|
||||
function next(delta){const candidates=visibleChanges();if(!candidates.length)return;const index=candidates.findIndex(c=>c.id===selected);select(candidates[(index+delta+candidates.length)%candidates.length]);}
|
||||
document.querySelector('#previous').addEventListener('click',()=>next(-1));document.querySelector('#next').addEventListener('click',()=>next(1));
|
||||
for(const svg of svgs)svg.addEventListener('click',event=>{const id=event.target.closest('[data-change-id]')?.dataset.changeId;if(byId.has(id))select(byId.get(id));});
|
||||
applyFilters();
|
||||
function expire(){const remaining=result.expires_at*1000-Date.now();if(remaining<=0){for(const view of views)view.destroy();document.querySelector('.workspace').replaceChildren();state.textContent='This comparison link has expired';}else setTimeout(expire,Math.min(2147483647,remaining));}expire();
|
||||
}
|
||||
start().catch(error=>{state.classList.remove('loading');state.textContent=error.name==='AbortError'?'':error.message;});
|
||||
@@ -1 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic API reference</title><link rel="stylesheet" href="/assets/upload.css"></head><body><main><p class="eyebrow">API V1</p><h1>Schematic rendering API</h1><p>Authenticate uploads with <code>X-API-Key</code>. Native binary OLE and ASCII SchDoc format 5.0 are accepted. Files are parsed asynchronously; HTTP 202 confirms admission, not rendering fidelity.</p><h2>Upload</h2><p><code>POST /api/v1/schematics/render</code></p><p>Multipart fields: <code>file</code> (required), <code>access</code> (private or unlisted; defaults to private), <code>expires_in</code> (optional seconds, bounded by server policy).</p><h2>Poll</h2><p><code>GET /api/v1/schematics/{id}/status</code></p><p>States: queued, processing, completed, failed. Private reads require <code>X-Viewer-Token</code> from the upload response, or the service API key. Completed responses include compatibility and diagnostics.</p><h2>Retrieve</h2><p><code>GET /api/v1/schematics/{id}/data</code> returns the versioned structured document. <code>GET /api/v1/schematics/{id}/svg</code> returns the generated vector drawing.</p><h2>View</h2><p>Open the exact <code>viewer_url</code> from the upload response. The private token is in its fragment. It is stored for this browser tab and sent only as a request header. The viewer has no panels or toolbar.</p><h2>Errors</h2><p>JSON errors include <code>success: false</code> and <code>error.code</code> / <code>error.message</code>. Common HTTP codes: 401 invalid service key; 404 missing or inaccessible schematic; 409 still processing; 410 expired; 413 too large; 415 wrong extension; 422 invalid schematic; 503 storage unavailable or queue full.</p><p><a href="/openapi.json">Download OpenAPI schema</a> · <a href="/">Upload a schematic</a></p></main></body></html>
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Schematic API reference</title><link rel="stylesheet" href="/assets/upload.css"></head><body><main><p class="eyebrow">API V1</p><h1>Schematic rendering API</h1><p>Authenticate uploads with <code>X-API-Key</code>. Native binary OLE and ASCII SchDoc format 5.0 are accepted. Files are parsed asynchronously; HTTP 202 confirms admission, not rendering fidelity.</p><h2>Upload</h2><p><code>POST /api/v1/schematics/render</code></p><p>Multipart fields: <code>file</code> (required), <code>access</code> (private or unlisted; defaults to private), <code>expires_in</code> (optional seconds, bounded by server policy).</p><h2>Poll</h2><p><code>GET /api/v1/schematics/{id}/status</code></p><p>States: queued, processing, completed, failed. Private reads require <code>X-Viewer-Token</code> from the upload response, or the service API key. Completed responses include compatibility and diagnostics.</p><h2>Retrieve</h2><p><code>GET /api/v1/schematics/{id}/data</code> returns the versioned structured document. <code>GET /api/v1/schematics/{id}/svg</code> returns the generated vector drawing.</p><h2>View</h2><p>Open the exact <code>viewer_url</code> from the upload response. The private token is in its fragment. It is stored for this browser tab and sent only as a request header. The viewer has no panels or toolbar.</p><h2>Compare two schematics</h2><p><code>POST /api/v1/comparisons</code> (alias <code>/api/compare</code>) accepts multipart <code>old_file</code> and <code>new_file</code> with the same API key. It returns HTTP 202 and a private comparison URL. Poll <code>GET /api/v1/comparisons/{id}</code> using the returned read token for the completed summary and structural changes. <a href="/compare">Open comparison upload</a>.</p><h2>Errors</h2><p>JSON errors include <code>success: false</code> and <code>error.code</code> / <code>error.message</code>. Common HTTP codes: 401 invalid service key; 404 missing or inaccessible schematic; 409 still processing; 410 expired; 413 too large; 415 wrong extension; 422 invalid schematic; 503 storage unavailable or queue full.</p><p><a href="/openapi.json">Download OpenAPI schema</a> · <a href="/">Upload a schematic</a></p></main></body></html>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Altium schematic upload</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · SCHDOC</p><h1>Open your schematic.</h1><p>Upload a native schematic to create a private, expiring viewer link.</p><form id="upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="file">Schematic file</label><input id="file" type="file" accept=".SchDoc" required><button type="submit">Render schematic <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Compatibility diagnostics are returned by the API. Review them before relying on visual fidelity.</p><a href="/docs">API reference</a></main></body></html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Altium schematic upload</title><link rel="stylesheet" href="/assets/upload.css"><script type="module" src="/assets/upload.js"></script></head><body><main><p class="eyebrow">NATIVE ALTIUM · SCHDOC</p><h1>Open your schematic.</h1><p>Upload a native schematic to create a private, expiring viewer link.</p><form id="upload"><label for="key">API key</label><input id="key" type="password" autocomplete="off" placeholder="Configured service key"><label for="file">Schematic file</label><input id="file" type="file" accept=".SchDoc" required><button type="submit">Render schematic <span aria-hidden="true">↗</span></button></form><output id="result" role="status"></output><p class="note">Compatibility diagnostics are returned by the API. Review them before relying on visual fidelity.</p><a href="/compare">Compare two schematics</a> · <a href="/docs">API reference</a></main></body></html>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Camera } from './camera.js';
|
||||
|
||||
/** Pointer/touch camera adapter shared by both comparison panes. */
|
||||
export function bindCamera(surface, svg, bounds, onChange = () => {}) {
|
||||
const camera = new Camera(bounds, surface.clientWidth, surface.clientHeight);
|
||||
let frame = 0;
|
||||
const pointers = new Map();
|
||||
function paint(notify = true) {
|
||||
if (!frame) frame = requestAnimationFrame(() => { frame = 0; svg.setAttribute('viewBox', camera.viewBox); });
|
||||
if (notify) onChange(view);
|
||||
}
|
||||
const view = { camera, paint,
|
||||
pose() { return { center: camera.world(camera.width/2,camera.height/2), scale: camera.scale }; },
|
||||
apply({center,scale}) { camera.scale=scale; camera.x=camera.width/2-center.x*scale; camera.y=camera.height/2-center.y*scale; paint(false); },
|
||||
fit() { camera.fit(); paint(); },
|
||||
focus(box) {
|
||||
const scale=Math.min(camera.width/Math.max(20,box.width+30),camera.height/Math.max(20,box.height+30),camera.fitScale*100);
|
||||
view.apply({center:{x:box.x+box.width/2,y:box.y+box.height/2},scale}); onChange(view);
|
||||
},
|
||||
destroy() { cancelAnimationFrame(frame); observer.disconnect(); events.abort(); }
|
||||
};
|
||||
const events = new AbortController(); const options={ signal:events.signal };
|
||||
surface.addEventListener('wheel',e => {
|
||||
e.preventDefault();const rect=surface.getBoundingClientRect();
|
||||
const delta=e.deltaY*(e.deltaMode===1?16:e.deltaMode===2?camera.height:1);
|
||||
camera.zoom(Math.exp(-Math.max(-400,Math.min(400,delta))*.002),e.clientX-rect.left,e.clientY-rect.top);paint();
|
||||
},{...options,passive:false});
|
||||
surface.addEventListener('pointerdown',e => {
|
||||
if(e.button!==0)return;surface.focus();surface.setPointerCapture(e.pointerId);
|
||||
pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
|
||||
},options);
|
||||
function metrics() {
|
||||
const p=[...pointers.values()], rect=surface.getBoundingClientRect();
|
||||
return { x:p.reduce((s,p)=>s+p.x,0)/p.length-rect.left,y:p.reduce((s,p)=>s+p.y,0)/p.length-rect.top,
|
||||
distance:p.length>1?Math.hypot(p[0].x-p[1].x,p[0].y-p[1].y):0 };
|
||||
}
|
||||
surface.addEventListener('pointermove',e => {
|
||||
if(!pointers.has(e.pointerId))return;
|
||||
const before=metrics();pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});const after=metrics();
|
||||
if(before.distance&&after.distance)camera.zoom(after.distance/before.distance,before.x,before.y);
|
||||
camera.pan(after.x-before.x,after.y-before.y);paint();
|
||||
},options);
|
||||
for(const type of ['pointerup','pointercancel','lostpointercapture'])surface.addEventListener(type,e=>pointers.delete(e.pointerId),options);
|
||||
surface.addEventListener('dblclick',()=>view.fit(),options);
|
||||
surface.addEventListener('keydown',e=>{
|
||||
if(e.key==='Home'||e.key==='0')camera.fit();
|
||||
else if(e.key==='+'||e.key==='=')camera.zoom(1.25,camera.width/2,camera.height/2);
|
||||
else if(e.key==='-')camera.zoom(.8,camera.width/2,camera.height/2);
|
||||
else if(e.key.startsWith('Arrow'))camera.pan(e.key==='ArrowLeft'?40:e.key==='ArrowRight'?-40:0,e.key==='ArrowUp'?40:e.key==='ArrowDown'?-40:0);
|
||||
else return;e.preventDefault();paint();
|
||||
},options);
|
||||
const observer=new ResizeObserver(()=>{
|
||||
const pose=view.pose();camera.resize(surface.clientWidth,surface.clientHeight);
|
||||
camera.fitScale=Math.min(camera.width/bounds.width,camera.height/bounds.height);view.apply(pose);
|
||||
});observer.observe(surface);paint(false);return view;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { bindCamera } from './interactions.js';
|
||||
const close=(a,b)=>assert.ok(Math.abs(a-b)<1e-8,`${a} != ${b}`);
|
||||
function setup(t) {
|
||||
const saved={raf:globalThis.requestAnimationFrame,caf:globalThis.cancelAnimationFrame,observer:globalThis.ResizeObserver};
|
||||
const frames=[];
|
||||
globalThis.requestAnimationFrame=fn=>{frames.push(fn);return frames.length;};globalThis.cancelAnimationFrame=()=>{};
|
||||
globalThis.ResizeObserver=class {observe(){}disconnect(){}};
|
||||
t.after(()=>{globalThis.requestAnimationFrame=saved.raf;globalThis.cancelAnimationFrame=saved.caf;globalThis.ResizeObserver=saved.observer;});
|
||||
function surface(left=100) {
|
||||
const surface=new EventTarget();Object.assign(surface,{clientWidth:800,clientHeight:600,
|
||||
getBoundingClientRect:()=>({left,top:20}),focus(){},setPointerCapture(){}});
|
||||
const attributes={};return {surface,svg:{setAttribute:(k,v)=>attributes[k]=v},attributes};
|
||||
}
|
||||
return {surface,flush:()=>{for(const fn of frames.splice(0))fn();}};
|
||||
}
|
||||
function send(surface,type,values){const event=new Event(type,{cancelable:true});Object.assign(event,values);surface.dispatchEvent(event);return event;}
|
||||
|
||||
test('wheel zoom uses the pane offset and synchronizes world cameras',t=>{
|
||||
const env=setup(t),a=env.surface(),b=env.surface(1000),bounds={x:0,y:-760,width:1150,height:760};
|
||||
let second;const first=bindCamera(a.surface,a.svg,bounds,source=>second.apply(source.pose()));second=bindCamera(b.surface,b.svg,bounds);
|
||||
const anchor=first.camera.world(250,200);
|
||||
const event=send(a.surface,'wheel',{deltaY:-120,deltaMode:0,clientX:350,clientY:220});
|
||||
assert.ok(event.defaultPrevented);
|
||||
close(first.camera.world(250,200).x,anchor.x);close(first.camera.world(250,200).y,anchor.y);
|
||||
close(first.pose().center.x,second.pose().center.x);close(first.camera.scale,second.camera.scale);
|
||||
env.flush();assert.equal(a.attributes.viewBox,b.attributes.viewBox);first.destroy();second.destroy();
|
||||
});
|
||||
|
||||
test('two-pointer pinch preserves the moving center without rasterizing',t=>{
|
||||
const env=setup(t),{surface,svg}=env.surface();const view=bindCamera(surface,svg,{x:0,y:0,width:100,height:100});
|
||||
send(surface,'pointerdown',{pointerId:1,button:0,clientX:200,clientY:200});
|
||||
send(surface,'pointerdown',{pointerId:2,button:0,clientX:300,clientY:200});
|
||||
const anchor=view.camera.world(150,180),scale=view.camera.scale;
|
||||
send(surface,'pointermove',{pointerId:2,clientX:400,clientY:200});
|
||||
close(view.camera.scale,scale*2);close(view.camera.world(200,180).x,anchor.x);close(view.camera.world(200,180).y,anchor.y);
|
||||
view.destroy();
|
||||
});
|
||||
|
||||
test('focus fits change bounds and detaching stops interaction',t=>{
|
||||
const env=setup(t),{surface,svg}=env.surface();const view=bindCamera(surface,svg,{x:0,y:0,width:1000,height:1000});
|
||||
view.focus({x:100,y:200,width:20,height:40});const center=view.pose().center;
|
||||
close(center.x,110);close(center.y,220);view.destroy();const scale=view.camera.scale;
|
||||
send(surface,'wheel',{deltaY:-120,deltaMode:0,clientX:200,clientY:200});close(view.camera.scale,scale);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Actual HTTP upload → durable comparison worker → private result/SVG check."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import time
|
||||
import httpx
|
||||
p=argparse.ArgumentParser();p.add_argument('old_file',type=Path);p.add_argument('new_file',type=Path);p.add_argument('--url',default='http://127.0.0.1:8000');args=p.parse_args()
|
||||
with httpx.Client(base_url=args.url,timeout=60) as client:
|
||||
response=client.post('/api/compare',headers={'X-API-Key':os.environ.get('API_KEY','')},
|
||||
files={'old_file':(args.old_file.name,args.old_file.read_bytes()),'new_file':(args.new_file.name,args.new_file.read_bytes())})
|
||||
assert response.status_code==202,response.text
|
||||
created=response.json();identifier=created['comparison_id'];headers={'X-Viewer-Token':created['viewer_token']};base='/api/v1/comparisons/'+identifier
|
||||
assert client.get(base).status_code==404
|
||||
deadline=time.monotonic()+120
|
||||
while time.monotonic()<deadline:
|
||||
result=client.get(base,headers=headers).json()
|
||||
if result['status'] in ('completed','failed'):break
|
||||
time.sleep(.5)
|
||||
assert result['status']=='completed',result
|
||||
for side in ('old','new'):
|
||||
svg=client.get(base+'/svg/'+side,headers=headers)
|
||||
assert svg.status_code==200 and '<svg' in svg.text
|
||||
assert client.get(base+'/svg/'+side).status_code==404
|
||||
assert client.get('/compare/'+identifier).status_code==200
|
||||
print(json.dumps({'id':identifier,'status':result['status'],'summary':result['summary'],
|
||||
'total_changed':result['total_changed'],'private_access_enforced':True},indent=2))
|
||||
مرجع در شماره جدید
مسدود کردن کاربر