Complete LinkSyncServer and LinkSyncExtension implementation
LinkSyncServer: - Fix app.py imports, add CORS middleware, lifespan events - Create api/routes.py router aggregator - Create config/settings.py for centralized configuration - Rewrite models/base.py with proper relationships and serialization - Rewrite all API endpoints with real DB integration (auth, links, collections, sync, queries, tags) - Add admin endpoints (user management, stats, audit log) - Complete query parser with recursive descent and proper precedence - Complete query executor with set operations and field filters - Set up Alembic migrations with initial schema - Create web interface (templates, CSS, JS) - Add 42 passing tests (auth, links, collections, queries) - Add deploy.ps1 and deploy.sh scripts - Update README with deployment workflow LinkSyncExtension: - Create utils/api.js (REST client with retries, auth, error handling) - Create utils/sync.js (3 sync modes + conflict detection) - Create utils/collection.js (collection management) - Create utils/query-engine.js (client-side query parser) - Rewrite background.js (sync loop, bookmark events, message routing) - Rewrite popup.js (tabs, settings modal, notifications, CRUD) - Update popup.html (tabbed interface, query builder, modal) - Update popup.css (full redesign) - Create content/content.js (page metadata extraction) - Create options.html/js (dedicated settings page) - Generate icons (48x48, 96x96) - Update manifest.json (host permissions, content scripts, options) - Create AGENTS.md
This commit is contained in:
233
LinkSyncExtension/utils/query-engine.js
Normal file
233
LinkSyncExtension/utils/query-engine.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// LinkSync Query Engine
|
||||
// Client-side query parser and executor for building queries
|
||||
|
||||
const QueryEngine = {
|
||||
tokenize(expression) {
|
||||
const tokens = [];
|
||||
let pos = 0;
|
||||
const len = expression.length;
|
||||
|
||||
while (pos < len) {
|
||||
const ch = expression[pos];
|
||||
|
||||
if (ch === " " || ch === "\t") {
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "(") {
|
||||
tokens.push({ type: "LPAREN", value: "(" });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ")") {
|
||||
tokens.push({ type: "RPAREN", value: ")" });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ",") {
|
||||
tokens.push({ type: "COMMA", value: "," });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 3) === "AND") {
|
||||
tokens.push({ type: "OPERATOR", value: "AND" });
|
||||
pos += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 2) === "OR") {
|
||||
tokens.push({ type: "OPERATOR", value: "OR" });
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 3) === "XOR") {
|
||||
tokens.push({ type: "OPERATOR", value: "XOR" });
|
||||
pos += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" || ch === '"') {
|
||||
const quote = ch;
|
||||
pos++;
|
||||
let value = "";
|
||||
while (pos < len && expression[pos] !== quote) {
|
||||
value += expression[pos];
|
||||
pos++;
|
||||
}
|
||||
pos++;
|
||||
tokens.push({ type: "TERM", value });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[a-zA-Z0-9_.\-]/.test(ch)) {
|
||||
let value = "";
|
||||
while (pos < len && /[a-zA-Z0-9_.\-:\/?&=%]/.test(expression[pos])) {
|
||||
value += expression[pos];
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (value.includes(":")) {
|
||||
const [field, ...rest] = value.split(":");
|
||||
const fieldValue = rest.join(":");
|
||||
const knownFields = ["url", "tag", "title", "description", "path", "id"];
|
||||
if (knownFields.includes(field.toLowerCase())) {
|
||||
tokens.push({ type: "FIELD", value: field.toUpperCase() });
|
||||
tokens.push({ type: "TERM", value: fieldValue });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push({ type: "TERM", value });
|
||||
continue;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
},
|
||||
|
||||
parse(expression) {
|
||||
if (!expression || !expression.trim()) return null;
|
||||
|
||||
const tokens = this.tokenize(expression);
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
const parser = new QueryParserState(tokens);
|
||||
return parser.parseOr();
|
||||
},
|
||||
|
||||
buildQueryString(ast) {
|
||||
if (!ast) return "";
|
||||
|
||||
switch (ast.type) {
|
||||
case "TERM":
|
||||
return ast.value;
|
||||
case "TERM_SET":
|
||||
return `(${ast.values.join(", ")})`;
|
||||
case "FIELD":
|
||||
return `${ast.field.toLowerCase()}:${ast.value}`;
|
||||
case "AND":
|
||||
case "OR":
|
||||
case "XOR":
|
||||
return `(${this.buildQueryString(ast.left)} ${ast.type} ${this.buildQueryString(ast.right)})`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
validate(expression) {
|
||||
try {
|
||||
const ast = this.parse(expression);
|
||||
return { valid: true, ast };
|
||||
} catch (e) {
|
||||
return { valid: false, error: e.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
class QueryParserState {
|
||||
constructor(tokens) {
|
||||
this.tokens = tokens;
|
||||
this.pos = 0;
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
|
||||
}
|
||||
|
||||
advance() {
|
||||
const token = this.current();
|
||||
this.pos++;
|
||||
return token;
|
||||
}
|
||||
|
||||
expect(type) {
|
||||
const token = this.current();
|
||||
if (!token) throw new Error(`Expected ${type}, got end of input`);
|
||||
if (token.type !== type) throw new Error(`Expected ${type}, got ${token.type}`);
|
||||
return this.advance();
|
||||
}
|
||||
|
||||
parseOr() {
|
||||
let left = this.parseAnd();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "OR") {
|
||||
this.advance();
|
||||
const right = this.parseAnd();
|
||||
left = { type: "OR", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parseAnd() {
|
||||
let left = this.parseXor();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "AND") {
|
||||
this.advance();
|
||||
const right = this.parseXor();
|
||||
left = { type: "AND", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parseXor() {
|
||||
let left = this.parsePrimary();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "XOR") {
|
||||
this.advance();
|
||||
const right = this.parsePrimary();
|
||||
left = { type: "XOR", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parsePrimary() {
|
||||
const token = this.current();
|
||||
if (!token) throw new Error("Unexpected end of input");
|
||||
|
||||
if (token.type === "LPAREN") {
|
||||
this.advance();
|
||||
const node = this.parseOr();
|
||||
this.expect("RPAREN");
|
||||
return node;
|
||||
}
|
||||
|
||||
if (token.type === "FIELD") {
|
||||
const field = this.advance().value;
|
||||
const valueToken = this.current();
|
||||
if (valueToken && valueToken.type === "TERM") {
|
||||
this.advance();
|
||||
return { type: "FIELD", field, value: valueToken.value };
|
||||
}
|
||||
return { type: "FIELD", field, value: "" };
|
||||
}
|
||||
|
||||
if (token.type === "TERM") {
|
||||
return this.parseTerm();
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected token: ${token.value}`);
|
||||
}
|
||||
|
||||
parseTerm() {
|
||||
const token = this.advance();
|
||||
const next = this.current();
|
||||
|
||||
if (next && next.type === "COMMA") {
|
||||
const values = [token.value];
|
||||
while (this.current() && this.current().type === "COMMA") {
|
||||
this.advance();
|
||||
const termToken = this.current();
|
||||
if (termToken && termToken.type === "TERM") {
|
||||
values.push(this.advance().value);
|
||||
}
|
||||
}
|
||||
return { type: "TERM_SET", values };
|
||||
}
|
||||
|
||||
return { type: "TERM", value: token.value };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user