All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.google.gerrit.server.index.change.ChangeField Maven / Gradle / Ivy

There is a newer version: 3.10.0-rc4
Show newest version
// Copyright (C) 2013 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package com.google.gerrit.server.index.change;

import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.gerrit.server.index.FieldDef.exact;
import static com.google.gerrit.server.index.FieldDef.fullText;
import static com.google.gerrit.server.index.FieldDef.intRange;
import static com.google.gerrit.server.index.FieldDef.integer;
import static com.google.gerrit.server.index.FieldDef.prefix;
import static com.google.gerrit.server.index.FieldDef.storedOnly;
import static com.google.gerrit.server.index.FieldDef.timestamp;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Sets;
import com.google.common.collect.Table;
import com.google.gerrit.common.data.SubmitRecord;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.ChangeMessage;
import com.google.gerrit.reviewdb.client.Comment;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RefNames;
import com.google.gerrit.server.OutputFormat;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.StarredChangesUtil;
import com.google.gerrit.server.index.FieldDef;
import com.google.gerrit.server.index.FieldDef.FillArgs;
import com.google.gerrit.server.index.SchemaUtil;
import com.google.gerrit.server.index.change.StalenessChecker.RefState;
import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.notedb.RobotCommentNotes;
import com.google.gerrit.server.project.SubmitRuleOptions;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ChangeStatusPredicate;
import com.google.gson.Gson;
import com.google.gwtorm.protobuf.CodecFactory;
import com.google.gwtorm.protobuf.ProtobufCodec;
import com.google.gwtorm.server.OrmException;
import com.google.protobuf.CodedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import org.eclipse.jgit.revwalk.FooterLine;

/**
 * Fields indexed on change documents.
 *
 * 

Each field corresponds to both a field name supported by {@link ChangeQueryBuilder} for * querying that field, and a method on {@link ChangeData} used for populating the corresponding * document fields in the secondary index. * *

Field names are all lowercase alphanumeric plus underscore; index implementations may create * unambiguous derived field names containing other characters. */ public class ChangeField { public static final int NO_ASSIGNEE = -1; private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson(); /** Legacy change ID. */ public static final FieldDef LEGACY_ID = integer("legacy_id").stored().build(cd -> cd.getId().get()); /** Newer style Change-Id key. */ public static final FieldDef ID = prefix(ChangeQueryBuilder.FIELD_CHANGE_ID).build(changeGetter(c -> c.getKey().get())); /** Change status string, in the same format as {@code status:}. */ public static final FieldDef STATUS = exact(ChangeQueryBuilder.FIELD_STATUS) .build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus()))); /** Project containing the change. */ public static final FieldDef PROJECT = exact(ChangeQueryBuilder.FIELD_PROJECT) .stored() .build(changeGetter(c -> c.getProject().get())); /** Project containing the change, as a prefix field. */ public static final FieldDef PROJECTS = prefix(ChangeQueryBuilder.FIELD_PROJECTS).build(changeGetter(c -> c.getProject().get())); /** Reference (aka branch) the change will submit onto. */ public static final FieldDef REF = exact(ChangeQueryBuilder.FIELD_REF).build(changeGetter(c -> c.getDest().get())); /** Topic, a short annotation on the branch. */ public static final FieldDef EXACT_TOPIC = exact("topic4").build(ChangeField::getTopic); /** Topic, a short annotation on the branch. */ public static final FieldDef FUZZY_TOPIC = fullText("topic5").build(ChangeField::getTopic); /** Submission id assigned by MergeOp. */ public static final FieldDef SUBMISSIONID = exact(ChangeQueryBuilder.FIELD_SUBMISSIONID).build(changeGetter(Change::getSubmissionId)); /** Last update time since January 1, 1970. */ public static final FieldDef UPDATED = timestamp("updated2").stored().build(changeGetter(Change::getLastUpdatedOn)); /** List of full file paths modified in the current patch set. */ public static final FieldDef> PATH = // Named for backwards compatibility. exact(ChangeQueryBuilder.FIELD_FILE) .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of())); public static Set getFileParts(ChangeData cd) throws OrmException { List paths = cd.currentFilePaths(); if (paths == null) { return ImmutableSet.of(); } Splitter s = Splitter.on('/').omitEmptyStrings(); Set r = new HashSet<>(); for (String path : paths) { for (String part : s.split(path)) { r.add(part); } } return r; } /** Hashtags tied to a change */ public static final FieldDef> HASHTAG = exact(ChangeQueryBuilder.FIELD_HASHTAG) .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet())); /** Hashtags with original case. */ public static final FieldDef> HASHTAG_CASE_AWARE = storedOnly("_hashtag") .buildRepeatable( cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet())); /** Components of each file path modified in the current patch set. */ public static final FieldDef> FILE_PART = exact(ChangeQueryBuilder.FIELD_FILEPART).buildRepeatable(ChangeField::getFileParts); /** Owner/creator of the change. */ public static final FieldDef OWNER = integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get())); /** The user assigned to the change. */ public static final FieldDef ASSIGNEE = integer(ChangeQueryBuilder.FIELD_ASSIGNEE) .build(changeGetter(c -> c.getAssignee() != null ? c.getAssignee().get() : NO_ASSIGNEE)); /** Reviewer(s) associated with the change. */ public static final FieldDef> REVIEWER = exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers())); @VisibleForTesting static List getReviewerFieldValues(ReviewerSet reviewers) { List r = new ArrayList<>(reviewers.asTable().size() * 2); for (Table.Cell c : reviewers.asTable().cellSet()) { String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey()); r.add(v); r.add(v + ',' + c.getValue().getTime()); } return r; } public static String getReviewerFieldValue(ReviewerStateInternal state, Account.Id id) { return state.toString() + ',' + id; } public static ReviewerSet parseReviewerFieldValues(Iterable values) { ImmutableTable.Builder b = ImmutableTable.builder(); for (String v : values) { int f = v.indexOf(','); if (f < 0) { continue; } int l = v.lastIndexOf(','); if (l == f) { continue; } b.put( ReviewerStateInternal.valueOf(v.substring(0, f)), Account.Id.parse(v.substring(f + 1, l)), new Timestamp(Long.valueOf(v.substring(l + 1, v.length())))); } return ReviewerSet.fromTable(b.build()); } /** Commit ID of any patch set on the change, using prefix match. */ public static final FieldDef> COMMIT = prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions); /** Commit ID of any patch set on the change, using exact match. */ public static final FieldDef> EXACT_COMMIT = exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT).buildRepeatable(ChangeField::getRevisions); private static Set getRevisions(ChangeData cd) throws OrmException { Set revisions = new HashSet<>(); for (PatchSet ps : cd.patchSets()) { if (ps.getRevision() != null) { revisions.add(ps.getRevision().get()); } } return revisions; } /** Tracking id extracted from a footer. */ public static final FieldDef> TR = exact(ChangeQueryBuilder.FIELD_TR) .buildRepeatable( (ChangeData cd, FillArgs a) -> { List footers = cd.commitFooters(); if (footers == null) { return ImmutableSet.of(); } return Sets.newHashSet(a.trackingFooters.extract(footers).values()); }); /** List of labels on the current patch set. */ @Deprecated public static final FieldDef> LABEL = exact(ChangeQueryBuilder.FIELD_LABEL).buildRepeatable(cd -> getLabels(cd, false)); /** List of labels on the current patch set including change owner votes. */ public static final FieldDef> LABEL2 = exact("label2").buildRepeatable(cd -> getLabels(cd, true)); private static Iterable getLabels(ChangeData cd, boolean owners) throws OrmException { Set allApprovals = new HashSet<>(); Set distinctApprovals = new HashSet<>(); for (PatchSetApproval a : cd.currentApprovals()) { if (a.getValue() != 0 && !a.isLegacySubmit()) { allApprovals.add(formatLabel(a.getLabel(), a.getValue(), a.getAccountId())); if (owners && cd.change().getOwner().equals(a.getAccountId())) { allApprovals.add( formatLabel(a.getLabel(), a.getValue(), ChangeQueryBuilder.OWNER_ACCOUNT_ID)); } distinctApprovals.add(formatLabel(a.getLabel(), a.getValue())); } } allApprovals.addAll(distinctApprovals); return allApprovals; } public static Set getAuthorParts(ChangeData cd) throws OrmException, IOException { return SchemaUtil.getPersonParts(cd.getAuthor()); } public static Set getCommitterParts(ChangeData cd) throws OrmException, IOException { return SchemaUtil.getPersonParts(cd.getCommitter()); } /** * The exact email address, or any part of the author name or email address, in the current patch * set. */ public static final FieldDef> AUTHOR = fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts); /** * The exact email address, or any part of the committer name or email address, in the current * patch set. */ public static final FieldDef> COMMITTER = fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts); public static final ProtobufCodec CHANGE_CODEC = CodecFactory.encoder(Change.class); /** Serialized change object, used for pre-populating results. */ public static final FieldDef CHANGE = storedOnly("_change").build(changeGetter(CHANGE_CODEC::encodeToByteArray)); public static final ProtobufCodec APPROVAL_CODEC = CodecFactory.encoder(PatchSetApproval.class); /** Serialized approvals for the current patch set, used for pre-populating results. */ public static final FieldDef> APPROVAL = storedOnly("_approval") .buildRepeatable(cd -> toProtos(APPROVAL_CODEC, cd.currentApprovals())); public static String formatLabel(String label, int value) { return formatLabel(label, value, null); } public static String formatLabel(String label, int value, Account.Id accountId) { return label.toLowerCase() + (value >= 0 ? "+" : "") + value + (accountId != null ? "," + formatAccount(accountId) : ""); } private static String formatAccount(Account.Id accountId) { if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) { return ChangeQueryBuilder.ARG_ID_OWNER; } return Integer.toString(accountId.get()); } /** Commit message of the current patch set. */ public static final FieldDef COMMIT_MESSAGE = fullText(ChangeQueryBuilder.FIELD_MESSAGE).build(ChangeData::commitMessage); /** Summary or inline comment. */ public static final FieldDef> COMMENT = fullText(ChangeQueryBuilder.FIELD_COMMENT) .buildRepeatable( cd -> { Set r = new HashSet<>(); for (Comment c : cd.publishedComments()) { r.add(c.message); } for (ChangeMessage m : cd.messages()) { r.add(m.getMessage()); } return r; }); /** Number of unresolved comments of the change. */ public static final FieldDef UNRESOLVED_COMMENT_COUNT = intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT) .stored() .build(ChangeData::unresolvedCommentCount); /** Whether the change is mergeable. */ public static final FieldDef MERGEABLE = exact(ChangeQueryBuilder.FIELD_MERGEABLE) .stored() .build( cd -> { Boolean m = cd.isMergeable(); if (m == null) { return null; } return m ? "1" : "0"; }); /** The number of inserted lines in this change. */ public static final FieldDef ADDED = intRange(ChangeQueryBuilder.FIELD_ADDED) .stored() .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null); /** The number of deleted lines in this change. */ public static final FieldDef DELETED = intRange(ChangeQueryBuilder.FIELD_DELETED) .stored() .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null); /** The total number of modified lines in this change. */ public static final FieldDef DELTA = intRange(ChangeQueryBuilder.FIELD_DELTA) .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null)); /** Users who have commented on this change. */ public static final FieldDef> COMMENTBY = integer(ChangeQueryBuilder.FIELD_COMMENTBY) .buildRepeatable( cd -> { Set r = new HashSet<>(); for (ChangeMessage m : cd.messages()) { if (m.getAuthor() != null) { r.add(m.getAuthor().get()); } } for (Comment c : cd.publishedComments()) { r.add(c.author.getId().get()); } return r; }); /** Star labels on this change in the format: <account-id>:<label> */ public static final FieldDef> STAR = exact(ChangeQueryBuilder.FIELD_STAR) .stored() .buildRepeatable( cd -> Iterables.transform( cd.stars().entries(), e -> StarredChangesUtil.StarField.create(e.getKey(), e.getValue()) .toString())); /** Users that have starred the change with any label. */ public static final FieldDef> STARBY = integer(ChangeQueryBuilder.FIELD_STARBY) .buildRepeatable(cd -> Iterables.transform(cd.stars().keySet(), Account.Id::get)); /** Opaque group identifiers for this change's patch sets. */ public static final FieldDef> GROUP = exact(ChangeQueryBuilder.FIELD_GROUP) .buildRepeatable( cd -> { Set r = Sets.newHashSetWithExpectedSize(1); for (PatchSet ps : cd.patchSets()) { r.addAll(ps.getGroups()); } return r; }); public static final ProtobufCodec PATCH_SET_CODEC = CodecFactory.encoder(PatchSet.class); /** Serialized patch set object, used for pre-populating results. */ public static final FieldDef> PATCH_SET = storedOnly("_patch_set").buildRepeatable(cd -> toProtos(PATCH_SET_CODEC, cd.patchSets())); /** Users who have edits on this change. */ public static final FieldDef> EDITBY = integer(ChangeQueryBuilder.FIELD_EDITBY) .buildRepeatable(cd -> cd.editsByUser().stream().map(Account.Id::get).collect(toSet())); /** Users who have draft comments on this change. */ public static final FieldDef> DRAFTBY = integer(ChangeQueryBuilder.FIELD_DRAFTBY) .buildRepeatable(cd -> cd.draftsByUser().stream().map(Account.Id::get).collect(toSet())); public static final Integer NOT_REVIEWED = -1; /** * Users the change was reviewed by since the last author update. * *

A change is considered reviewed by a user if the latest update by that user is newer than * the latest update by the change author. Both top-level change messages and new patch sets are * considered to be updates. * *

If the latest update is by the change owner, then the special value {@link #NOT_REVIEWED} is * emitted. */ public static final FieldDef> REVIEWEDBY = integer(ChangeQueryBuilder.FIELD_REVIEWEDBY) .stored() .buildRepeatable( cd -> { Set reviewedBy = cd.reviewedBy(); if (reviewedBy.isEmpty()) { return ImmutableSet.of(NOT_REVIEWED); } List result = new ArrayList<>(reviewedBy.size()); for (Account.Id id : reviewedBy) { result.add(id.get()); } return result; }); // Submit rule options in this class should never use fastEvalLabels. This // slows down indexing slightly but produces correct search results. public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT = SubmitRuleOptions.defaults().allowClosed(true).allowDraft(true).build(); public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT = SubmitRuleOptions.defaults().build(); /** * JSON type for storing SubmitRecords. * *

Stored fields need to use a stable format over a long period; this type insulates the index * from implementation changes in SubmitRecord itself. */ static class StoredSubmitRecord { static class StoredLabel { String label; SubmitRecord.Label.Status status; Integer appliedBy; } SubmitRecord.Status status; List labels; String errorMessage; StoredSubmitRecord(SubmitRecord rec) { this.status = rec.status; this.errorMessage = rec.errorMessage; if (rec.labels != null) { this.labels = new ArrayList<>(rec.labels.size()); for (SubmitRecord.Label label : rec.labels) { StoredLabel sl = new StoredLabel(); sl.label = label.label; sl.status = label.status; sl.appliedBy = label.appliedBy != null ? label.appliedBy.get() : null; this.labels.add(sl); } } } private SubmitRecord toSubmitRecord() { SubmitRecord rec = new SubmitRecord(); rec.status = status; rec.errorMessage = errorMessage; if (labels != null) { rec.labels = new ArrayList<>(labels.size()); for (StoredLabel label : labels) { SubmitRecord.Label srl = new SubmitRecord.Label(); srl.label = label.label; srl.status = label.status; srl.appliedBy = label.appliedBy != null ? new Account.Id(label.appliedBy) : null; rec.labels.add(srl); } } return rec; } } public static final FieldDef> SUBMIT_RECORD = exact("submit_record").buildRepeatable(cd -> formatSubmitRecordValues(cd)); public static final FieldDef> STORED_SUBMIT_RECORD_STRICT = storedOnly("full_submit_record_strict") .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT)); public static final FieldDef> STORED_SUBMIT_RECORD_LENIENT = storedOnly("full_submit_record_lenient") .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT)); public static void parseSubmitRecords( Collection values, SubmitRuleOptions opts, ChangeData out) { checkArgument(!opts.fastEvalLabels()); List records = parseSubmitRecords(values); if (records.isEmpty()) { // Assume no values means the field is not in the index; // SubmitRuleEvaluator ensures the list is non-empty. return; } out.setSubmitRecords(opts, records); // Cache the fastEvalLabels variant as well so it can be used by // ChangeJson. out.setSubmitRecords(opts.toBuilder().fastEvalLabels(true).build(), records); } @VisibleForTesting static List parseSubmitRecords(Collection values) { return values .stream() .map(v -> GSON.fromJson(v, StoredSubmitRecord.class).toSubmitRecord()) .collect(toList()); } @VisibleForTesting static List storedSubmitRecords(List records) { return Lists.transform(records, r -> GSON.toJson(new StoredSubmitRecord(r)).getBytes(UTF_8)); } private static Iterable storedSubmitRecords(ChangeData cd, SubmitRuleOptions opts) throws OrmException { return storedSubmitRecords(cd.submitRecords(opts)); } public static List formatSubmitRecordValues(ChangeData cd) throws OrmException { return formatSubmitRecordValues( cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT), cd.change().getOwner()); } @VisibleForTesting static List formatSubmitRecordValues(List records, Account.Id changeOwner) { List result = new ArrayList<>(); for (SubmitRecord rec : records) { result.add(rec.status.name()); if (rec.labels == null) { continue; } for (SubmitRecord.Label label : rec.labels) { String sl = label.status.toString() + ',' + label.label.toLowerCase(); result.add(sl); String slc = sl + ','; if (label.appliedBy != null) { result.add(slc + label.appliedBy.get()); if (label.appliedBy.equals(changeOwner)) { result.add(slc + ChangeQueryBuilder.OWNER_ACCOUNT_ID.get()); } } } } return result; } /** * All values of all refs that were used in the course of indexing this document. * *

Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}. */ public static final FieldDef> REF_STATE = storedOnly("ref_state") .buildRepeatable( (cd, a) -> { List result = new ArrayList<>(); Project.NameKey project = cd.change().getProject(); cd.editRefs() .values() .forEach(r -> result.add(RefState.of(r).toByteArray(project))); cd.starRefs() .values() .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(a.allUsers))); if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) { ChangeNotes notes = cd.notes(); result.add( RefState.create(notes.getRefName(), notes.getMetaId()).toByteArray(project)); notes.getRobotComments(); // Force loading robot comments. RobotCommentNotes robotNotes = notes.getRobotCommentNotes(); result.add( RefState.create(robotNotes.getRefName(), robotNotes.getMetaId()) .toByteArray(project)); cd.draftRefs() .values() .forEach(r -> result.add(RefState.of(r).toByteArray(a.allUsers))); } return result; }); /** * All ref wildcard patterns that were used in the course of indexing this document. * *

Emitted as UTF-8 encoded strings of the form {@code project:ref/name/*}. See {@link * RefStatePattern} for the pattern format. */ public static final FieldDef> REF_STATE_PATTERN = storedOnly("ref_state_pattern") .buildRepeatable( (cd, a) -> { Change.Id id = cd.getId(); Project.NameKey project = cd.change().getProject(); List result = new ArrayList<>(3); result.add( RefStatePattern.create( RefNames.REFS_USERS + "*/" + RefNames.EDIT_PREFIX + id + "/*") .toByteArray(project)); result.add( RefStatePattern.create(RefNames.refsStarredChangesPrefix(id) + "*") .toByteArray(a.allUsers)); if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) { result.add( RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*") .toByteArray(a.allUsers)); } return result; }); private static String getTopic(ChangeData cd) throws OrmException { Change c = cd.change(); if (c == null) { return null; } return firstNonNull(c.getTopic(), ""); } private static List toProtos(ProtobufCodec codec, Collection objs) throws OrmException { List result = Lists.newArrayListWithCapacity(objs.size()); ByteArrayOutputStream out = new ByteArrayOutputStream(256); try { for (T obj : objs) { out.reset(); CodedOutputStream cos = CodedOutputStream.newInstance(out); codec.encode(obj, cos); cos.flush(); result.add(out.toByteArray()); } } catch (IOException e) { throw new OrmException(e); } return result; } private static FieldDef.Getter changeGetter(Function func) { return in -> in.change() != null ? func.apply(in.change()) : null; } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy