This commit is contained in:
2023-10-13 21:06:23 +03:00
parent 129e3af471
commit 9e37a7f730
34 changed files with 845 additions and 780 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -1,14 +1,14 @@
{
"main.js": {
"file": "assets/main.55e6cd6b.js",
"file": "assets/main.4387b1b7.js",
"src": "main.js",
"isEntry": true,
"css": [
"assets/main.e0c13bde.css"
"assets/main.0c108e59.css"
]
},
"main.css": {
"file": "assets/main.e0c13bde.css",
"file": "assets/main.0c108e59.css",
"src": "main.css"
}
}
+30 -12
View File
@@ -40,17 +40,17 @@
<!-- </div>-->
<div class="offcanvas-body">
<div class="accordion" id="accordionPanelsStayOpenExample">
<div class="accordion">
<div class="accordion-item">
<h2 class="accordion-header" id="panelsStayOpen-headingOne">
<h2 class="accordion-header" id="panelsStayOpen-headingMain">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#panelsStayOpen-collapseOne" aria-expanded="true"
aria-controls="panelsStayOpen-collapseOne">
data-bs-target="#panelsStayOpen-collapseMain" aria-expanded="true"
aria-controls="panelsStayOpen-collapseMain">
Main
</button>
</h2>
<div id="panelsStayOpen-collapseOne" class="accordion-collapse collapse show"
aria-labelledby="panelsStayOpen-headingOne">
<div id="panelsStayOpen-collapseMain" class="accordion-collapse collapse show"
aria-labelledby="panelsStayOpen-headingMain">
<div class="accordion-body">
<NavbarMenu
schemas={ channel.schemas.filter((sc) => sc.isEntry)}
@@ -60,18 +60,36 @@
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="panelsStayOpen-headingTwo">
<h2 class="accordion-header" id="panelsStayOpen-headingOther">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#panelsStayOpen-collapseTwo" aria-expanded="false"
aria-controls="panelsStayOpen-collapseTwo">
data-bs-target="#panelsStayOpen-collapseOther" aria-expanded="false"
aria-controls="panelsStayOpen-collapseOther">
Other
</button>
</h2>
<div id="panelsStayOpen-collapseTwo" class="accordion-collapse collapse"
aria-labelledby="panelsStayOpen-headingTwo">
<div id="panelsStayOpen-collapseOther" class="accordion-collapse collapse"
aria-labelledby="panelsStayOpen-headingOther">
<div class="accordion-body">
<NavbarMenu
schemas={ channel.schemas.filter((sc) => !sc.isEntry)}
schemas={ channel.schemas.filter((sc) => !sc.isEntry && sc.type !== "files")}
schema={schema}
/>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="panelsStayOpen-headingFS">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#panelsStayOpen-collapseFS" aria-expanded="false"
aria-controls="panelsStayOpen-collapseFS">
Filesystem
</button>
</h2>
<div id="panelsStayOpen-collapseFS" class="accordion-collapse collapse"
aria-labelledby="panelsStayOpen-headingFS">
<div class="accordion-body">
<NavbarMenu
schemas={ channel.schemas.filter((sc) => sc.type === "files")}
schema={schema}
/>
</div>
+27 -4
View File
@@ -4,9 +4,11 @@
import Icon from "../../common/Icon.svelte";
import SortFields from "./SortFields.svelte";
import AppliedFilter from "./AppliedFilter.svelte";
import {getContext} from "svelte";
import {getContext,createEventDispatcher} from "svelte";
const channel = getContext("channel");
const dispatch = createEventDispatcher();
export let sort;
export let schema;
export let operators;
@@ -21,6 +23,21 @@
let csvUrl = url.pathname + "/csv?" + url.searchParams.toString();
function search(e){
e.preventDefault();
const data = new FormData(e.target);
let filterKey = data.keys().next().value;
let filterValue = data.values().next().value;
const url = new URL(modalUrl ?? window.location.href);
url.searchParams.set("skip", "0");
url.searchParams.set(filterKey, filterValue);
if (inModal) {
dispatch("refresh", url);
} else {
window.location = url;
}
}
function uploadComplete(e) {
records = e.detail;
}
@@ -49,9 +66,15 @@
on:refresh
/>
<form method="GET">
<input type="search" name="filter[data.{schema.fields[0].name}_regex]" placeholder="Search"
class="form-control" required>
<form method="GET" on:submit={search}>
{#if schema.fields[0]?.name}
<input type="search" name="filter[data.{schema.fields[0].name }_regex]" placeholder="Search"
class="form-control" required>
{:else}
<input type="search" name="filter[_file.originalName_regex]" placeholder="Search"
class="form-control" required>
{/if}
</form>
+2 -2
View File
@@ -51,13 +51,13 @@
<ReferenceInline
bind:graph
{record}
{schema}
{field}
{validationErrors}
/>
{:else if field.info.name === "reference" && field.layout === "table"}
<ReferenceTable
bind:graph
{id}
{record}
{field}
{validationErrors}
@@ -73,8 +73,8 @@
{:else if field.info.name === "reference"}
<Reference
bind:graph
{id}
{record}
{schema}
{field}
{validationErrors}
/>
+3 -4
View File
@@ -8,9 +8,8 @@
const channel = getContext("channel");
export let graph;
export let record;
let parentEdgesByField = graph.edges
.filter((edge) => edge.source !== record.id && edge.depth === 0)
let parentEdgesByField = graph.parentEdges
.filter((edge) => edge.source !== record.id && edge.depth === 1)
.reduce((carry, edge) => {
let schemaField = edge.sourceSchema + edge.field;
@@ -32,7 +31,7 @@
}
return carry;
}, {});
console.log(parentEdgesByField)
let childrenEdgesByField = graph.edges
.filter((edge) => edge.source === record.id && edge.depth === 0)
.reduce((carry, edge) => {
+28 -29
View File
@@ -1,5 +1,5 @@
<script>
import {afterUpdate, createEventDispatcher, onMount,} from "svelte";
import {afterUpdate, createEventDispatcher, onMount,getContext} from "svelte";
import {isEqual} from "lodash";
import FormField from "./FormField.svelte";
@@ -8,9 +8,9 @@
import StatusSelect from "./StatusSelect.svelte";
import ErrorAlert from "../common/ErrorAlert.svelte";
const channel = getContext("channel");
const dispatch = createEventDispatcher();
export let schema;
export let schemas;
export let record;
export let graph = {
records: [],
@@ -18,7 +18,7 @@
};
export let isCreateMode;
let originalContent;
let activeContentTab = "_default";
let activeContentTab = "";
let hasUnsavedData = false;
$: validationErrors = null;
$: errorMessage = validationErrors
@@ -28,7 +28,7 @@
: null;
let activeFields = schema.fields.filter(
(f) => f.trashed === false && f.name !== "id"
(f) => f.name !== "id"
);
let tabname = "_default";
@@ -116,7 +116,7 @@
axios
.post("/records", {
.post(channel.lucentUrl + "/records", {
record: record,
edges: graph.edges,
isCreateMode: isCreateMode,
@@ -159,24 +159,23 @@
<div class=" mt-1">
<ContentTabs
{schema}
{isCreateMode}
bind:active={activeContentTab}
{record}
{schema}
{isCreateMode}
bind:active={activeContentTab}
{record}
/>
<FilePreview {record} {schema}/>
<!-- <fieldset disabled="disabled"> -->
{#each activeFields as field (field.name)}
{#if fieldToTabs[activeContentTab].includes(field.name)}
{#if activeContentTab === field.group}
<FormField
bind:data={record.data}
bind:graph={graph}
{field}
{schema}
{schemas}
{record}
{validationErrors}
{isCreateMode}
bind:data={record.data}
bind:graph={graph}
{field}
{schema}
{record}
{validationErrors}
{isCreateMode}
/>
{/if}
{/each}
@@ -189,26 +188,26 @@
{/if}
{#if isCreateMode}
<button
class="ms-2 btn btn-primary btn-spinner"
on:click={save}
class="ms-2 btn btn-primary btn-spinner"
on:click={save}
>
<span
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
/>
Add
</button>
{:else}
<button
disabled={!hasUnsavedData}
class="ms-2 btn btn-primary btn-spinner"
on:click={save}
disabled={!hasUnsavedData}
class="ms-2 btn btn-primary btn-spinner"
on:click={save}
>
<span
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
class="spinner-border spinner-border-sm"
role="status"
aria-hidden="true"
/>
Save
</button>
+1 -4
View File
@@ -63,10 +63,7 @@
</div>
<style>
.button-file {
width: 64px;
height: 65px;
}
.card .trash-button {
display: none;
}
@@ -2,30 +2,32 @@
import Icon from "../common/Icon.svelte";
import {createEventDispatcher, onMount} from "svelte";
import {createEventDispatcher, onMount, getContext} from "svelte";
import Preview from "../files/Preview.svelte";
import InlineEdit from "./InlineEdit.svelte";
import Reference from "../content/elements/Reference.svelte";
import File from "../content/elements/File.svelte";
const channel = getContext("channel");
const dispatch = createEventDispatcher();
export let isFirst;
export let isLast;
export let toDelete = false;
export let schemas;
export let record;
let editRecord;
let schema = schemas.find((aschema) => aschema.name === record.schema);
let editGraph;
let schema = channel.schemas.find((aschema) => aschema.name === record.schema);
$: editMode = false;
$: expanded = false;
function editInline(e) {
e.preventDefault();
axios
.get("/records/editInline/" + record.id)
.get(channel.lucentUrl + "/records/editInline/" + record.id)
.then((response) => {
record = response.data;
editRecord = response.data;
editRecord = response.data.record;
editGraph = response.data.graph;
editMode = true;
})
.catch((error) => {
@@ -76,7 +78,7 @@
function deleteFromChannel(e) {
e.preventDefault();
axios
.post("/records/status/trashed", [record])
.post(channel.lucentUrl +"/records/status/trashed", [record])
.then((response) => {
dispatch("remove", record.id);
})
@@ -92,13 +94,13 @@
<p>Item was removed from the current record.</p>
<p>
<button
class="btn btn-sm btn-outline border border-1 border-dark"
on:click={undo}>Undo
class="btn btn-sm btn-outline border border-1 border-dark"
on:click={undo}>Undo
</button
>
<button
class="btn btn-sm btn-danger "
on:click={deleteFromChannel}
class="btn btn-sm btn-danger "
on:click={deleteFromChannel}
>Delete completely from channel
</button
>
@@ -110,26 +112,26 @@
</div>
{:else if editMode === true}
<InlineEdit
{schema}
{schemas}
record={editRecord}
isCreateMode={false}
on:cancel={cancel}
on:inlinesaved={handleInlinesaved}
{schema}
record={editRecord}
graph={editGraph}
isCreateMode={false}
on:cancel={cancel}
on:inlinesaved={handleInlinesaved}
/>
{:else}
<div class="lx-card mt-4 bg-primary bg-opacity-10">
<div class="actions">
<small class="text-muted">{schema.label}</small>
<button
class="btn btn-sm btn-link"
on:click|preventDefault={editInline}
class="btn btn-sm btn-link"
on:click|preventDefault={editInline}
>
<Icon icon="pencil" width={12} height={12}/>
</button>
<button
class="btn btn-sm btn-link"
on:click={(e) => (expanded = !expanded)}
class="btn btn-sm btn-link"
on:click={(e) => (expanded = !expanded)}
>
{#if expanded}
<Icon icon="compress" width={12} height={12}/>
@@ -139,19 +141,19 @@
</button>
<div class="dropdown d-inline-block">
<button
class="btn btn-link btn-sm"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
class="btn btn-link btn-sm"
type="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<Icon icon="ellipsis"/>
</button>
<div class="dropdown-menu">
<a
class="dropdown-item"
href="/records/{record.id}"
target="_blank"
class="dropdown-item"
href="/records/{record.id}"
target="_blank"
>Edit in new tab
</a>
<button class="dropdown-item" on:click={trash}>
@@ -162,16 +164,16 @@
{#if !isFirst}
<button
class="btn btn-sm btn-outline-primary border-0"
on:click|preventDefault={moveup}
class="btn btn-sm btn-outline-primary border-0"
on:click|preventDefault={moveup}
>
<Icon icon="circle-chevron-up"/>
</button>
{/if}
{#if !isLast}
<button
class="btn btn-sm btn-outline-primary border-0"
on:click|preventDefault={movedn}
class="btn btn-sm btn-outline-primary border-0"
on:click|preventDefault={movedn}
>
<Icon icon="circle-chevron-down"/>
</button>
@@ -189,7 +191,7 @@
>{field.label}</span
>
{#if field.ui === "reference"}
<Reference {record} {schemas} {field}/>
<Reference {record} {field}/>
{:else if field.ui === "file"}
<File {record} {field}/>
{:else}
-113
View File
@@ -1,113 +0,0 @@
<script>
import Mustache from "mustache";
import { imgurl } from "../files/imageserver";
import { uniqBy } from "lodash";
import { onMount, getContext } from "svelte";
import { Network } from "vis-network/esnext";
import "vis-network/styles/vis-network.css";
const channelurl = getContext("channelurl");
export let schemas;
export let recordGraph;
let network;
let allEdges = recordGraph._graph.edges.map((e) => {
e.fieldLabel = schemas
.find((s) => s.uid === e.fromSchema)
.fields.find((f) => f.name === e.field).label;
return e;
});
let nodes = recordGraph._graph.nodes.map((r) => {
let nodeOptions = {
id: r.data.id,
label: renderTitle(
schemas.find((s) => s.uid === r._sys.schema),
r
),
borderWidth: 0,
color: {
background:
r.data.id === recordGraph.data.id ? "#0b5d1e" : "#eeeeee",
},
font: {
multi: true,
color: r.data.id === recordGraph.data.id ? "#fff" : "#333",
},
};
nodeOptions.shape = "box";
if (r._file?.path) {
nodeOptions.shape = "image";
nodeOptions.image = imgurl(r, 64, 64, "crop");
}
return nodeOptions;
});
nodes = uniqBy(nodes, (n) => n.id);
// create an array with edges
let networkEdges = allEdges.map((e) => {
return {
from: e.from,
to: e.to,
label: e.fieldLabel,
arrows: {
to: {
enabled: true,
type: "arrow",
scaleFactor: 0.5,
},
},
font: { align: "middle" },
};
});
let data = {
nodes: nodes,
edges: networkEdges,
};
let options = {
physics: false,
layout: {
hierarchical: {
enabled: true,
nodeSpacing: 150,
treeSpacing: 200,
direction: "UD",
sortMethod: "directed",
},
},
};
onMount(() => {
let networkInstance = new Network(network, data, options);
networkInstance.on("doubleClick", function (params) {
if (params.nodes[0]) {
window.location = channelurl + "/records/" + params.nodes[0];
}
});
});
function renderTitle(schema, record) {
const template = `${schema.titleTemplate}\n <i>${schema.label}</i>`;
Mustache.parse(template);
let recordData = { ...record, ...record.data };
return Mustache.render(template, recordData);
}
</script>
<div class="lx-card">
<div class="network-container" bind:this={network} />
</div>
<style>
.network-container {
width: 100%;
height: 80vh;
max-height: 800px;
}
</style>
@@ -1,6 +1,6 @@
<script>
import {getContext} from "svelte";
import {uniqBy} from "lodash";
import {insertEdges} from "./reference";
import PreviewCard from "../PreviewCard.svelte";
import {getErrorMessage} from "./errorMessage";
import {sortByField} from "../../edges/sortEdges";
@@ -37,26 +37,7 @@
function insert(e) {
e.preventDefault();
const recordsToInsert = e.detail.records;
const action = e.detail.action;
let newEdges = recordsToInsert.map((r) => {
return {
target: r.id,
source: record.id,
sourceSchema: record.schema,
targetSchema: r.schema,
field: field.name,
rank: ""
};
});
let replacedEdges = graph.edges;
if (action === "replace") {
replacedEdges = replacedEdges.filter((edge) => edge.field !== field.name);
}
graph.records = uniqBy([...graph.records, ...recordsToInsert], (r) => r.id);
graph.edges = uniqBy([...replacedEdges, ...newEdges], (edge) => edge.target + edge.field);
graph = insertEdges(graph,record,e.detail.records,field.name,e.detail.action);
}
</script>
@@ -68,7 +49,6 @@
{/if}
<div class="inline-card-wrapper">
<ReferenceInlineButtons
{field}
buttonClass="mt-2"
recordId={null}
schemas={collections}
@@ -1,6 +1,5 @@
<script>
import {getContext} from "svelte";
import {uniqBy} from "lodash";
import {previewTitle} from "../Preview";
import {getErrorMessage} from "./errorMessage";
import {sortByField} from "../../edges/sortEdges";
@@ -8,10 +7,10 @@
import Sortable from "../../libs/Sortable.svelte";
import RenderField from "../../content/RenderField.svelte";
import Icon from "../../common/Icon.svelte";
import {insertEdges} from "./reference.js";
const channel = getContext("channel");
export let field;
export let record;
export let graph;
export let validationErrors;
@@ -46,26 +45,8 @@
function insert(e) {
e.preventDefault();
const recordsToInsert = e.detail.records;
const action = e.detail.action;
let newEdges = recordsToInsert.map((r) => {
return {
target: r.id,
source: record.id,
sourceSchema: record.schema,
targetSchema: r.schema,
field: field.name,
rank: ""
};
});
let replacedEdges = graph.edges;
if (action === "replace") {
replacedEdges = replacedEdges.filter((edge) => edge.field !== field.name);
}
graph.records = uniqBy([...graph.records, ...recordsToInsert], (r) => r.id);
graph.edges = uniqBy([...replacedEdges, ...newEdges], (edge) => edge.target + edge.field);
graph = insertEdges(graph,record,e.detail.records,field.name,e.detail.action);
console.log(graph)
}
$:visibleColumns = [];
@@ -118,7 +99,7 @@
<div class="d-flex align-items-center">
<a
class="me-2 text-decoration-none text-dark fs-6"
href="/records/{record.id}"
href="{channel.lucentUrl}/records/{record.id}"
target="_blank"
>
{previewTitle(channel.schemas, record)}
@@ -9,6 +9,7 @@
import RenderField from "../../content/RenderField.svelte";
import Icon from "../../common/Icon.svelte";
import Datalist from "./Datalist.svelte";
import {insertEdges} from "./reference.js";
const channel = getContext("channel");
export let field;
@@ -62,26 +63,7 @@
function insert(e, insertRecord) {
e.preventDefault();
const recordsToInsert = [insertRecord];
const action = e.detail.action;
let newEdges = recordsToInsert.map((r) => {
return {
target: r.id,
source: record.id,
sourceSchema: record.schema,
targetSchema: r.schema,
field: field.name,
rank: ""
};
});
let replacedEdges = graph.edges;
if (action === "replace") {
replacedEdges = replacedEdges.filter((edge) => edge.field !== field.name);
}
graph.records = uniqBy([...graph.records, ...recordsToInsert], (r) => r.id);
graph.edges = uniqBy([...replacedEdges, ...newEdges], (edge) => edge.target + edge.field);
graph = insertEdges(graph,record,[insertRecord],field.name,e.detail.action);
}
const updateResults = debounce((e) => {
@@ -2,22 +2,38 @@
export let field;
export let value;
export let search;
function select(e, option) {
function select(e, option) {
e.preventDefault();
value = option;
search = "";
}
</script>
{#if field.selectOptions}
{#each field.selectOptions as suggestion (suggestion)}
<div
on:click={(e) => select(e, suggestion)}
on:keypress={(e) => select(e, suggestion)}
>
{#if Array.isArray(field.selectOptions)}
{#each field.selectOptions as suggestion (suggestion)}
<div
on:click={(e) => select(e, suggestion)}
on:keypress={(e) => select(e, suggestion)}
>
<span class="dropdown-item">
{suggestion}
</span>
</div>
{/each}
</div>
{/each}
{:else}
{#each Object.entries(field.selectOptions) as [k, v] (k)}
<div
on:click={(e) => select(e, k)}
on:keypress={(e) => select(e, k)}
>
<span class="dropdown-item">
{v}
</span>
</div>
{/each}
{/if}
{/if}
+6 -2
View File
@@ -75,7 +75,6 @@
autocomplete="off"
readonly={field.readonly && !isCreateMode}
/>
<div class="dropdown-menu w-100">
<Selectlist
{field}
@@ -87,7 +86,12 @@
{#if value}
<span class="badge rounded-pill bg-light text-dark fs-6 mt-3">
<div class="d-flex align-items-center ">
{value}
{#if Array.isArray(field.selectOptions)}
{value}
{:else}
{field.selectOptions[value]}
{/if}
<button
on:click|preventDefault={(e) => (value = "")}
type="button"
@@ -0,0 +1,23 @@
import {uniqBy} from "lodash";
export function insertEdges(graph, sourceRecord, targetRecords, fieldName, action = "") {
let newEdges = targetRecords.map((r) => {
return {
target: r.id,
source: sourceRecord.id,
sourceSchema: sourceRecord.schema,
targetSchema: r.schema,
field: fieldName,
rank: ""
};
});
let replacedEdges = graph.edges;
if (action === "replace") {
replacedEdges = replacedEdges.filter((edge) => edge.field !== field.name);
}
graph.records = uniqBy([...graph.records, ...targetRecords], (r) => r.id);
graph.edges = uniqBy([...replacedEdges, ...newEdges], (edge) => edge.source + edge.target + edge.field);
return graph;
}
+7 -5
View File
@@ -158,7 +158,7 @@ class RecordController extends Controller
public function newInline(Request $request)
{
$schema = $this->channelService->getSchema($request->input("schema"));
$schema = $this->channelService->getSchema($request->input("schema"))->get();
$record = $this->recordService->createEmpty($schema);
$queryRecord = QueryRecord::fromRecord($record);
@@ -221,12 +221,14 @@ class RecordController extends Controller
->limit(1)
->childrenDepth(2)
->parentsDepth(1)
->tree();
->run();
$record = $graph->records->first();
return ok(
[
"graph" => $graph->toArray(),
"record" => $graph->first()->toArray(),
"graph" => toArray($graph),
"record" => toArray($record)
]
);
}
@@ -287,7 +289,7 @@ class RecordController extends Controller
$newGraph = $this->query
->filter(["id" => $recordId])
->limit(10)
->childrenDepth(2)
->childrenDepth(1)
->parentsDepth(1)
->run();
-185
View File
@@ -1,185 +0,0 @@
<?php
namespace Lucent\Query;
use Illuminate\Support\Facades\DB;
final class Filter
{
private array $operatorsList;
public function __construct(
public array $arguments = [],
)
{
}
public function add(array $arguments): Filter
{
$this->arguments = $arguments;
return $this;
}
private function formatArguments(array $arguments): array
{
return (array)collect($arguments)->reduce(fn($c, $v, $k) => $this->formatArgument($c, $v, $k), []);
}
private function formatArgument(array $arguments, mixed $value, string $filter): array
{
$operator = $this->detectOperator($filter);
$field = $this->detectField($filter, $operator);
$formattedValue = match ($operator) {
"eq" => $this->formatText($value),
"ne" => $this->formatText($value),
"eqnum" => $this->formatNumber($value),
"nenum" => $this->formatNumber($value),
"object" => $this->formatText($value),
"in" => $this->formatListString($value),
"nin" => $this->formatListString($value),
"innum" => $this->formatListNum($value),
"ninnum" => $this->formatListNum($value),
"eqtrue" => true,
"eqfalse" => false,
"netrue" => true,
"nefalse" => false,
"regex" => "%{$value}%",
"gt" => \is_numeric($value) ? floatval($value) : $value,
"gte" => \is_numeric($value) ? floatval($value) : $value,
"lt" => \is_numeric($value) ? floatval($value) : $value,
"lte" => \is_numeric($value) ? floatval($value) : $value,
"null" => null,
"nnull" => null,
"exists" => true,
"nexists" => false,
default => $value,
};
$matchedOperator = $this->operatorsList[$operator];
$arguments[] = [
"field" => str_replace(".", "->", $field),
"operator" => $matchedOperator->db,
"value" => $formattedValue
];
return $arguments;
}
private function formatText(string $value): string
{
return trim($value);
}
private function formatNumber(string $value): float
{
return \floatval($value);
}
private function formatListString(mixed $value): array
{
if (\is_string($value)) {
$value = explode(",", $value);
}
return \array_map(fn($v) => $this->formatText($v), $value);
}
private function formatListNum(mixed $value): array
{
if (\is_string($value)) {
$value = explode(",", $value);
}
return \array_map(fn($v) => $this->formatNumber($v), $value);
}
private function detectOperator(string $filter): string
{
$exploded = \explode("_", $filter);
$candidate = end($exploded);
$operatorsListNames = collect($this->operatorsList)->map(fn($o) => $o->name)->toArray();
if (\in_array($candidate, $operatorsListNames)) {
return $candidate;
}
return 'eq';
}
private function detectField(string $filter, string $operator): string
{
$exploded = \explode("_", $filter);
$candidate = array_pop($exploded);
if ($candidate == $operator) {
return \implode("_", $exploded);
}
return $filter;
}
private function formatReferences(Query $query): array
{
[$arguments, $referenceArguments] = $this->separateMainFromReferenceArguments();
if (empty($referenceArguments)) {
return [$arguments, []];
};
$subqueries = collect($referenceArguments)->reduce(function ($c, $v, $k) {
$keyWithoutRef = str_replace("children.", "", $k);
[$field] = explode(".", $keyWithoutRef);
$referenceField = str_replace($field . ".", "", $keyWithoutRef);
$c[$field][$referenceField] = $v;
return $c;
}, []);
$sourceIds = collect($subqueries)->reduce(function ($c, $subquery, $k) use ($query) {
$graph = $query->filter($subquery)->run();
if (!$graph->hasResults()) {
return $c;
}
$targetIds = collect($graph->records)->pluck("id");
$sourceIds = DB::table("edges")->whereIn("target", $targetIds)->where("field", $k)->get()->pluck("source");
return array_merge($c, $sourceIds->toArray());
}, []);
return [$arguments, [
"field" => "id",
"operator" => "in",
"value" => $sourceIds
]];
}
private function separateMainFromReferenceArguments(): array
{
return collect($this->arguments)->partition(function ($v, $k) {
if (!str_starts_with($k, "children.")) {
return true;
}
return false;
})->toArray();
}
public function run(Query $query): array
{
[$argumentsWithoutReferences, $referencesFilter] = $this->formatReferences($query);
$this->operatorsList = Operator::list();
$formattedArguments = $this->formatArguments($argumentsWithoutReferences);
if (!empty($referencesFilter)) {
$formattedArguments[] = $referencesFilter;
}
return $formattedArguments;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Lucent\Query\Filter;
final class AndFilter implements Filter
{
public function __construct(
public array $params = []
)
{
}
public function toArray():array{
return $this->params;
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Lucent\Query\Filter;
class Argument
{
public function __construct(
public string $field,
public string $operator,
public mixed $value,
){}
}
+7
View File
@@ -0,0 +1,7 @@
<?php namespace Lucent\Query\Filter;
interface Filter
{
public function toArray():array;
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Lucent\Query\Filter;
final class OrFilter implements Filter
{
public function __construct(
public array $params = []
)
{
}
public function toArray():array{
return $this->params;
}
}
+290
View File
@@ -0,0 +1,290 @@
<?php
namespace Lucent\Query;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Lucent\Query\Filter\AndFilter;
use Lucent\Query\Filter\Argument;
use Lucent\Query\Filter\Filter;
use Lucent\Query\Filter\OrFilter;
final class FilterParser
{
public function __construct(public Application $app)
{
}
/**
* @param array $arguments
* @return array<Argument>
*/
private function formatArguments(array $arguments): array
{
return collect($arguments)->reduce(function ($c, $v, $k) {
$c[] = $this->formatArgument($v, $k);
return $c;
}, []);
}
private function formatArgument(mixed $value, string $filter): Argument
{
$operator = $this->detectOperator($filter);
$field = $this->detectField($filter, $operator);
$formattedValue = match ($operator) {
"eq" => $this->formatText($value),
"ne" => $this->formatText($value),
"eqnum" => $this->formatNumber($value),
"nenum" => $this->formatNumber($value),
"object" => $this->formatText($value),
"in" => $this->formatListString($value),
"nin" => $this->formatListString($value),
"innum" => $this->formatListNum($value),
"ninnum" => $this->formatListNum($value),
"eqtrue" => true,
"eqfalse" => false,
"netrue" => true,
"nefalse" => false,
"regex" => "%{$value}%",
"gt" => is_numeric($value) ? floatval($value) : $value,
"gte" => is_numeric($value) ? floatval($value) : $value,
"lt" => is_numeric($value) ? floatval($value) : $value,
"lte" => is_numeric($value) ? floatval($value) : $value,
"null" => null,
"nnull" => null,
"exists" => true,
"nexists" => false,
default => $value,
};
$matchedOperator = Operator::list()[$operator];
return new Argument(
field: str_replace(".", "->", $field),
operator: $matchedOperator->db,
value: $formattedValue
);
}
private function formatText(string $value): string
{
return trim($value);
}
private function formatNumber(string $value): float
{
return floatval($value);
}
private function formatListString(mixed $value): array
{
if (is_string($value)) {
$value = explode(",", $value);
}
return array_map(fn($v) => $this->formatText($v), $value);
}
private function formatListNum(mixed $value): array
{
if (\is_string($value)) {
$value = explode(",", $value);
}
return \array_map(fn($v) => $this->formatNumber($v), $value);
}
private function detectOperator(string $filter): string
{
$exploded = \explode("_", $filter);
$candidate = end($exploded);
$operatorsListNames = collect(Operator::list())->map(fn($o) => $o->name)->toArray();
if (\in_array($candidate, $operatorsListNames)) {
return $candidate;
}
return 'eq';
}
private function detectField(string $filter, string $operator): string
{
$exploded = explode("_", $filter);
$candidate = array_pop($exploded);
if ($candidate === $operator) {
return implode("_", $exploded);
}
return $filter;
}
private function formatReferences(array $referenceArguments): Argument
{
$subqueries = collect($referenceArguments)->reduce(function ($c, $v, $k) {
$keyWithoutRef = str_replace("children.", "", $k);
[$field] = explode(".", $keyWithoutRef);
$referenceField = str_replace($field . ".", "", $keyWithoutRef);
$c[$field][$referenceField] = $v;
return $c;
}, []);
$sourceIds = collect($subqueries)->reduce(function ($c, $subquery, $k) {
$query = $this->app->make(Query::class);
$graph = $query->filter($subquery)->run();
if (!$graph->hasResults()) {
return $c;
}
$targetIds = collect($graph->records)->pluck("id");
$sourceIds = DB::table("edges")->whereIn("target", $targetIds)->where("field", $k)->get()->pluck("source");
return array_merge($c, $sourceIds->toArray());
}, []);
return new Argument(
field: "id",
operator: "in",
value: $sourceIds
);
}
private function separateMainFromReferenceArguments(Filter $arguments): array
{
return collect($arguments->toArray())->partition(function ($v, $k) {
if (!str_starts_with($k, "children.")) {
return true;
}
return false;
})->toArray();
}
/**
* @return array<Argument>
*/
private function parseArguments(Filter $arguments): array
{
[$normalArguments, $referenceArguments] = $this->separateMainFromReferenceArguments($arguments);
$formattedArguments = $this->formatArguments($normalArguments);
if (!empty($referenceArguments)) {
$formattedArguments[] = $this->formatReferences($referenceArguments);
}
return $formattedArguments;
}
public function parse(Builder $builder, Filter $filter): Builder
{
$arguments = $this->parseArguments($filter);
return match (get_class($filter)) {
AndFilter::class => $this->parseAnd($builder, $arguments),
OrFilter::class => $this->parseOr($builder, $arguments),
};
}
/**
* @param array<Argument> $arguments
*/
private function parseAnd(Builder $builder, array $arguments): Builder
{
$ignoredFilters = [];
foreach ($arguments as $argument) {
if (in_array($argument->field, $ignoredFilters)) {
continue;
} else if ($argument->operator == "in") {
$builder->whereIn($argument->field, $argument->value);
} else if ($argument->operator == "nin") {
$builder->whereNotIn($argument->field, $argument->value);
} elseif ($argument->operator == "eqobject") {
$object = $argument->value;
// unset related filters used here
$addToIgnored = collect($arguments)
->filter(fn(Argument $f) => str_starts_with($f->field, $object))
->values()
->map(fn($f) => $f->field)
->toArray();
$ignoredFilters = array_merge($ignoredFilters, $addToIgnored);
$objectFilters = collect($arguments)
->filter(fn($f) => str_starts_with($f->field, $object))
->values()
->reduce(function ($c, $f) use ($object) {
$field = str_replace($object . "->", "", $f->field);
$c[$field] = $f->value;
return $c;
});
// target result
// filter[data.previousNames_object]=previousNames&filter[previousNames.name_eq]=alpha&filter[previousNames.id_eqnum]=24
// $query->whereJsonContains("data->previousNames", [["name" => "alpha", "id" => 24]]);
// $query->whereJsonContains($filter["field"], [$objectFilters]);
} else {
$builder->where($argument->field, $argument->operator, $argument->value);
}
}
return $builder;
}
/**
* @param array<Argument> $arguments
*/
private function parseOr(Builder $builder, array $arguments): Builder
{
$builder->where(function (Builder $orBuilder) use ($arguments) {
$ignoredFilters = [];
foreach ($arguments as $argument) {
if (in_array($argument->field, $ignoredFilters)) {
continue;
} else if ($argument->operator == "in") {
$orBuilder->orWhereIn($argument->field, $argument->value);
} else if ($argument->operator == "nin") {
$orBuilder->orWhereNotIn($argument->field, $argument->value);
} elseif ($argument->operator == "eqobject") {
$object = $argument->value;
// unset related filters used here
$addToIgnored = collect($arguments)
->filter(fn(Argument $f) => str_starts_with($f->field, $object))
->values()
->map(fn($f) => $f->field)
->toArray();
$ignoredFilters = array_merge($ignoredFilters, $addToIgnored);
$objectFilters = collect($arguments)
->filter(fn($f) => str_starts_with($f->field, $object))
->values()
->reduce(function ($c, $f) use ($object) {
$field = str_replace($object . "->", "", $f->field);
$c[$field] = $f->value;
return $c;
});
// target result
// filter[data.previousNames_object]=previousNames&filter[previousNames.name_eq]=alpha&filter[previousNames.id_eqnum]=24
// $query->whereJsonContains("data->previousNames", [["name" => "alpha", "id" => 24]]);
// $query->whereJsonContains($filter["field"], [$objectFilters]);
} else {
$orBuilder->orWhere($argument->field, $argument->operator, $argument->value);
}
}
});
return $builder;
}
}
+36 -1
View File
@@ -16,6 +16,7 @@ final class Graph
public function __construct(
public Collection $records,
public Collection $edges,
public Collection $parentEdges,
public ?int $total = null,
)
{
@@ -66,6 +67,15 @@ final class Graph
public function tree(): Collection
{
return $this->getRootRecords()
->map([$this, 'findParents'])
->map([$this, 'findChildren'])
;
return $rootRecords;
return $this->records->filter(function (QueryRecord $record) {
return $this->edges->filter(fn(Edge $ed) => $ed->target == $record->id)->isEmpty();
})->values()
@@ -81,7 +91,6 @@ final class Graph
foreach ($recordEdges as $element) {
$groupRecordEdges[$element->field][] = $element;
}
$children = [];
foreach ($groupRecordEdges as $field => $edges) {
@@ -99,4 +108,30 @@ final class Graph
return $record;
}
public function findParents(QueryRecord $record): QueryRecord
{
$recordEdges = $this->parentEdges->filter(fn(Edge $ed) => $ed->target === $record->id)->values()->sort(fn($a, $b) => $a->rank <=> $b->rank)->values();
$groupRecordEdges = [];
foreach ($recordEdges as $element) {
$groupRecordEdges[$element->field][] = $element;
}
$parents = [];
foreach ($groupRecordEdges as $field => $edges) {
$parents[$field] = [];
foreach ($edges as $anEdge) {
$aRecord = $this->records->filter(fn(QueryRecord $rec) => $rec->id == $anEdge->source)->values();
if (empty($aRecord[0])) {
continue;
}
$parents[$field][] = $this->findParents($aRecord[0]);
}
}
$record->_parents = $parents;
return $record;
}
}
+61 -62
View File
@@ -4,9 +4,10 @@ namespace Lucent\Query;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Lucent\Channel\ChannelService;
use Lucent\Edge\Edge;
use Lucent\Primitive\Collection;
use Lucent\Query\Filter\AndFilter;
use Lucent\Query\Filter\OrFilter;
use Lucent\Record\InputFormatter;
use Lucent\Record\QueryRecord;
use Lucent\Record\Record;
@@ -14,11 +15,14 @@ use Lucent\Record\Record;
final class Query
{
public Filter $filter;
/**
* @var array<AndFilter> $filters
*/
public array $filters;
public QueryOptions $options;
public function __construct(
public readonly ChannelService $channelService,
public readonly FilterParser $filterParser,
public readonly InputFormatter $inputFormatter,
)
{
@@ -27,7 +31,13 @@ final class Query
public function filter(array $filterArguments): Query
{
$this->filter = new Filter($filterArguments);
$this->filters[] = new AndFilter($filterArguments);
return $this;
}
public function orFilter(array $filterArguments): Query
{
$this->filters[] = new OrFilter($filterArguments);
return $this;
}
@@ -62,16 +72,22 @@ final class Query
->get()->toArray();
}
$resultsRecordsUnique = collect(array_merge($resultsRecords, $edgeRecords))->unique("id")->values()->toArray();
$resultEdges = collect(array_merge($resultChildrenEdges, $resultParentEdges))
->unique(fn($edge) => $edge->source . $edge->target . $edge->field)
->toArray();
// $resultEdges = collect(array_merge($resultChildrenEdges, $resultParentEdges))
// ->unique(fn($edge) => $edge->source . $edge->target . $edge->field)
// ->toArray();
return $this->formatRecords($resultsRecordsUnique, $resultEdges);
$this->reset();
return $this->formatRecords($resultsRecordsUnique, $resultChildrenEdges, $resultParentEdges);
}
private function formatRecords(array $records, array $edges): Graph
private function reset()
{
$this->options = new QueryOptions();
$this->filters = [];
}
private function formatRecords(array $records, array $edges, array $parentEdges): Graph
{
$queryRecords = collect($records)->map(function ($recordData) {
@@ -89,10 +105,17 @@ final class Query
})->sortBy("rank")->values()->toArray();
$queryParentEdges = collect($parentEdges)->map(function ($edgeData) {
return Edge::fromArray((array)$edgeData);
})->sortBy("rank")->values()->toArray();
return new Graph(
new Collection($queryRecords),
new Collection($queryEdges),
new Collection($queryParentEdges),
);
}
@@ -103,61 +126,21 @@ final class Query
}
private function parseFilters(Builder $query): Builder
{
$filters = $this->filter->run(new Query($this->channelService, $this->inputFormatter));
$ignoredFilters = [];
foreach ($filters as $filter) {
if (in_array($filter["field"], $ignoredFilters)) {
continue;
} else if ($filter["operator"] == "in") {
$query->whereIn($filter["field"], $filter["value"]);
} else if ($filter["operator"] == "nin") {
$query->whereNotIn($filter["field"], $filter["value"]);
} elseif ($filter["operator"] == "eqobject") {
$object = $filter["value"];
// unset related filters used here
$addToIgnored = collect($filters)
->filter(fn($f) => str_starts_with($f["field"], $object))
->values()
->map(fn($f) => $f["field"])
->toArray();
$ignoredFilters = array_merge($ignoredFilters, $addToIgnored);
$objectFilters = collect($filters)
->filter(fn($f) => str_starts_with($f["field"], $object))
->values()
->reduce(function ($c, $f) use ($object) {
$field = str_replace($object . "->", "", $f["field"]);
$c[$field] = $f["value"];
return $c;
});
// target result
// filter[data.previousNames_object]=previousNames&filter[previousNames.name_eq]=alpha&filter[previousNames.id_eqnum]=24
// $query->whereJsonContains("data->previousNames", [["name" => "alpha", "id" => 24]]);
// $query->whereJsonContains($filter["field"], [$objectFilters]);
} else {
$query->where($filter["field"], $filter["operator"], $filter["value"]);
}
foreach ($this->filters as $filter) {
$query = $this->filterParser->parse($query, $filter);
}
$query->whereIn("status", $this->options->status);
return $query;
}
/**
* @throws SubqueryNoResultException
*/
private function mainQuery(): array
{
$query = DB::table("records");
$query = $this->parseFilters($query);
if($this->options->limit > 0){
if ($this->options->limit > 0) {
$query->limit($this->options->limit);
$query->offset($this->options->skip);
}
@@ -174,14 +157,19 @@ final class Query
function getChildren(array $ids): array
{
$subquery = DB::table('edges AS g')
->select(DB::raw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field, 0 as depth '))
->whereIn('source', $ids)
->limit($this->options->childrenLimit)
->select(DB::raw('g.source,g.target,g.rank,g.sourceSchema,g.targetSchema,g.field, 1 as depth '))
->whereIn('source', $ids);
if (!empty($this->options->childrenFields)) {
$subquery->whereIn('field', $this->options->childrenFields);
}
$subquery->limit($this->options->childrenLimit)
->unionAll(
DB::table(DB::raw("edges AS g, search_graph AS sg "))
->selectRaw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field,sg.depth + 1 as depth')
->whereRaw("g.source = sg.target")
->where("sg.depth", "<=", $this->options->childrenDepth)
->where("depth", "<", $this->options->childrenDepth)
->orderBy("rank")
);
@@ -191,18 +179,17 @@ final class Query
->get()->toArray();
}
private
function getParents(array $ids): array
private function getParents(array $ids): array
{
$subquery = DB::table('edges AS g')
->select(DB::raw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field, 0 as depth '))
->select(DB::raw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field, 1 as depth '))
->limit($this->options->parentsLimit)
->whereIn('g.target', $ids)
->unionAll(
DB::table(DB::raw("edges AS g, search_graph AS sg "))
->selectRaw('g.source,g.target,g.rank,"g"."sourceSchema","g"."targetSchema",g.field,sg.depth + 1 as depth')
->whereRaw("g.target = sg.source")
->where("sg.depth", "<=", $this->options->parentsDepth)
->where("depth", "<", $this->options->parentsDepth)
->orderBy("rank")
);
@@ -251,6 +238,18 @@ final class Query
return $this;
}
public function childrenFields(array $fields): Query
{
$this->options->childrenFields = $fields;
return $this;
}
public function parentFields(array $fields): Query
{
$this->options->parentFields = $fields;
return $this;
}
public function parentsDepth(int $depth): Query
{
$this->options->parentsDepth = $depth;
+2
View File
@@ -13,6 +13,8 @@ final class QueryOptions
public int $parentsDepth = -1,
public int $childrenLimit = -1,
public int $parentsLimit = -1,
public array $childrenFields = [],
public array $parentFields = [],
public array $sort = [],
public array $status = ["published", "draft"]
)
-43
View File
@@ -1,43 +0,0 @@
<?php
namespace Lucent\Query;
use Lucent\Edge\Edge;
use Lucent\Primitive\Collection;
use Lucent\Record\QueryRecord;
final class QueryResult
{
/**
* @param Collection<QueryRecord> $records
* @param Collection<Edge> $edges
* @param int|null $total
*/
public function __construct(
public Collection $records,
public Collection $edges,
public ?int $total = null,
)
{
}
public function getTotal(): ?int
{
return $this->total;
}
public function hasResults(): bool
{
return !empty($this->records);
}
public function graph(): Graph
{
return new Graph($this->records, $this->edges);
}
}
-17
View File
@@ -1,17 +0,0 @@
<?php
namespace Lucent\Query;
use Exception;
final class SubqueryNoResultException extends Exception
{
// Redefine the exception so message isn't optional
public function __construct(string $message, int $code = 0, Exception $previous = null)
{
// make sure everything is assigned properly
parent::__construct($message, $code, $previous);
}
}
+1
View File
@@ -16,6 +16,7 @@ class QueryRecord
public bool $isRoot,
public ?File $_file = null,
public array $_children = [],
public array $_parents = [],
)
{
}
+19 -11
View File
@@ -83,9 +83,11 @@ readonly class RecordService
_file: $uploadResult->recordFile,
);
$errors = $this->recordValidator->check($schemaName, $record->data, $uniqueEdgesCollection);
if ($errors->isNotEmpty()) {
$this->recordValidator->throwException($errors);
if (Status::from($status) === Status::PUBLISHED) {
$errors = $this->recordValidator->check($schemaName, $record->data, $uniqueEdgesCollection);
if ($errors->isNotEmpty()) {
$this->recordValidator->throwException($errors);
}
}
RecordRepo::create($record);
@@ -117,6 +119,7 @@ readonly class RecordService
}
$formattedData = $this->inputFormatter->fill($record->schema, new RecordData($data));
$uniqueEdgesCollection = null;
if ($updateEdges) {
$uniqueEdges = collect($edges)
->map(function ($edge, $index) {
@@ -127,9 +130,17 @@ readonly class RecordService
->unique(fn($e) => $e['field'] . $e['source'] . $e['target'] . $e['sourceSchema'])
->values()->toArray();
$uniqueEdgesCollection = EdgeCollection::fromArray($uniqueEdges);
$errors = $this->recordValidator->check($record->schema, $formattedData, $uniqueEdgesCollection);
} else {
$errors = $this->recordValidator->check($record->schema, $formattedData, null);
}
if (Status::from($status) === Status::PUBLISHED) {
$errors = $this->recordValidator->check($record->schema, $record->data, $uniqueEdgesCollection);
if ($errors->isNotEmpty()) {
$this->recordValidator->throwException($errors);
}
if ($errors->isNotEmpty()) {
$this->recordValidator->throwException($errors);
}
}
@@ -143,9 +154,7 @@ readonly class RecordService
);
if ($errors->isNotEmpty()) {
$this->recordValidator->throwException($errors);
}
RecordRepo::update($newRecord);
if ($updateEdges) {
@@ -237,7 +246,6 @@ readonly class RecordService
public function createEmpty(
Schema $schema,
string $userId,
): Record
{
@@ -252,7 +260,7 @@ readonly class RecordService
id: Id::new(),
schema: $schema->name,
status: Status::DRAFT,
_sys: System::newRecord($userId),
_sys: System::newRecord($this->authService->currentUserId()),
data: $formattedData,
_file: null,
);
+3
View File
@@ -42,6 +42,9 @@ class StaticGenerator
}
if(!file_exists(public_path($directory."/index.html"))){
if(file_exists(public_path($directory))){
rmdir(public_path($directory));
}
continue;
}
unlink(public_path($directory."/index.html"));
+6 -6
View File
@@ -7,13 +7,13 @@
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>@yield('title') - Lucent Data Platform</title>
<!-- if development -->
@php
echo '<script type="module" crossorigin src="http://127.0.0.1:5173/@vite/client"></script>';
@endphp
<script type="module" crossorigin src="http://127.0.0.1:5173/main.js"></script>
{{-- @php--}}
{{-- echo '<script type="module" crossorigin src="http://127.0.0.1:5173/@vite/client"></script>';--}}
{{-- @endphp--}}
{{-- <script type="module" crossorigin src="http://127.0.0.1:5173/main.js"></script>--}}
<!-- if production -->
{{-- <link rel="stylesheet" href="/vendor/lucent/dist/{{ $manifest['main.css']["file"] }}" />--}}
{{-- <script type="module" src="/vendor/lucent/dist/{{ $manifest['main.js']["file"] }}"></script>--}}
<link rel="stylesheet" href="/vendor/lucent/dist/{{ $manifest['main.css']["file"] }}" />
<script type="module" src="/vendor/lucent/dist/{{ $manifest['main.js']["file"] }}"></script>
<link rel="icon" type="image/x-icon" href="/favicon.ico">