gt:                 return type + ":" + quotedContent;
context: package filter.expression;
  
  import java.time.LocalDate;
  import java.time.LocalDateTime;
  import java.time.temporal.ChronoUnit;
  import java.util.*;
  import java.util.function.Function;
  import java.util.function.Predicate;
  import java.util.regex.Matcher;
  import java.util.regex.Pattern;
  import java.util.stream.Collectors;
  
  import backend.resource.*;
  import filter.ParseException;
  import util.Utility;
  import backend.interfaces.IModel;
  import filter.MetaQualifierInfo;
  import filter.QualifierApplicationException;
  
  public class Qualifier implements FilterExpression {
  
      public static final Qualifier EMPTY = new Qualifier(QualifierType.EMPTY, "");
      public static final Qualifier FALSE = new Qualifier(QualifierType.FALSE, "");
  
      private final QualifierType type;
  
      // Only one of these will be present at a time
      private Optional<DateRange> dateRange = Optional.empty();
      private Optional<String> content = Optional.empty();
      private Optional<LocalDate> date = Optional.empty();
      private Optional<NumberRange> numberRange = Optional.empty();
      private Optional<Integer> number = Optional.empty();
      private List<SortKey> sortKeys = new ArrayList<>();
  
      // Copy constructor
      public Qualifier(Qualifier other) {
          this.type = other.getType();
          if (other.getDateRange().isPresent()) {
              this.dateRange = other.getDateRange();
          } else if (other.getDate().isPresent()) {
              this.date = other.getDate();
          } else if (other.getContent().isPresent()) {
              this.content = other.getContent();
          } else if (other.getNumberRange().isPresent()) {
              this.numberRange = other.getNumberRange();
          } else if (other.getNumber().isPresent()) {
              this.number = other.getNumber();
          } else if (!other.sortKeys.isEmpty()) {
              this.sortKeys = new ArrayList<>(other.sortKeys);
          } else {
              assert false : "Unrecognised content type! You may have forgotten to add it above";
          }
      }
  
      public Qualifier(QualifierType type, String content) {
          this.type = type;
          this.content = Optional.of(content);
      }
  
      public Qualifier(QualifierType type, NumberRange numberRange) {
          this.type = type;
          this.numberRange = Optional.of(numberRange);
      }
  
      public Qualifier(QualifierType type, DateRange dateRange) {
          this.type = type;
          this.dateRange = Optional.of(dateRange);
      }
  
      public Qualifier(QualifierType type, LocalDate date) {
          this.type = type;
          this.date = Optional.of(date);
      }
  
      public Qualifier(QualifierType type, int number) {
          this.type = type;
          this.number = Optional.of(number);
      }
  
      public Qualifier(QualifierType type, List<SortKey> keys) {
          this.type = type;
          this.sortKeys = new ArrayList<>(keys);
      }
  
      public static FilterExpression replaceMilestoneAliases(IModel model, FilterExpression expr) {
          List<String> repoIds = getMetaQualifierContent(expr, QualifierType.REPO).stream()
                  .map(String::toLowerCase)
                  .collect(Collectors.toList());
  
          if (repoIds.isEmpty()) {
              repoIds.add(model.getDefaultRepo().toLowerCase());
          }
  
          List<TurboMilestone> allMilestones = model.getMilestones().stream()
                  .filter(ms -> ms.getDueDate().isPresent())
                  .filter(ms -> repoIds.contains(ms.getRepoId().toLowerCase()))
                  .sorted((a, b) -> getMilestoneDueDateComparator().compare(a, b))
                  .collect(Collectors.toList());
  
          Optional<Integer> currentMilestoneIndex = getCurrentMilestoneIndex(allMilestones);
  
          if (!currentMilestoneIndex.isPresent()) {
              return expr;
          }
  
          return expr.map(q -> {
              if (Qualifier.isMilestoneQualifier(q)) {
                  return q.convertMilestoneAliasQualifier(allMilestones, currentMilestoneIndex.get());
              } else {
                  return q;
              }
          });
      }
  
      /**
       * Helper function for testing a filter expression against an issue.
       * Ensures that meta-qualifiers are taken care of.
       * Should always be used over isSatisfiedBy.
       */
      public static boolean process(IModel model, FilterExpression expr, TurboIssue issue) {
          FilterExpression exprWithNormalQualifiers = expr.filter(Qualifier::shouldNotBeStripped);
          List<Qualifier> metaQualifiers = expr.find(Qualifier::isMetaQualifier);
  
          // Preprocessing for repo qualifier
          boolean containsRepoQualifier = metaQualifiers.stream()
                  .map(Qualifier::getType)
                  .collect(Collectors.toList())
                  .contains(QualifierType.REPO);
  
          if (!containsRepoQualifier) {
              exprWithNormalQualifiers = new Conjunction(
                  new Qualifier(QualifierType.REPO, model.getDefaultRepo()),
                  exprWithNormalQualifiers);
          }
  
          return exprWithNormalQualifiers.isSatisfiedBy(model, issue, new MetaQualifierInfo(metaQualifiers));
      }
  
      private static Optional<Integer> getCurrentMilestoneIndex(List<TurboMilestone> allMilestones) {
          if (allMilestones.isEmpty()) {
              return Optional.empty();
          }
  
          int currentIndex = 0;
  
          for (TurboMilestone checker : allMilestones) {
              boolean overdue = checker.getDueDate().isPresent() &&
                      checker.getDueDate().get().isBefore(LocalDate.now());
              boolean relevant = !(overdue && checker.getOpenIssues() == 0);
  
              if (checker.isOpen() && relevant) {
                  return Optional.of(currentIndex);
              }
  
              currentIndex++;
          }
  
          // if no open milestone, set current as one after last milestone
          // - this means that no such milestone, which will return no issue
          return Optional.of(allMilestones.size());
      }
  
      public static HashSet<String> getMetaQualifierContent(FilterExpression expr, QualifierType qualifierType) {
          HashSet<String> contents = new HashSet<>();
          List<Qualifier> panelMetaQualifiers = expr.find(Qualifier::isMetaQualifier);
  
          panelMetaQualifiers.forEach(metaQualifier -> {
              if (metaQualifier.getType().equals(qualifierType) && metaQualifier.getContent().isPresent()) {
                  contents.add(metaQualifier.getContent().get());
              }
          });
  
          return contents;
      }
  
      private static LocalDateTime currentTime = null;
  
      private static LocalDateTime getCurrentTime() {
          if (currentTime == null) {
              return LocalDateTime.now();
          } else {
              return currentTime;
          }
      }
  
      /**
       * For testing. Stubs the current time so time-related qualifiers work properly.
       */
      public static void setCurrentTime(LocalDateTime dateTime) {
          currentTime = dateTime;
      }
  
      @Override
      public boolean isEmpty() {
          return type == QualifierType.EMPTY;
      }
  
      public boolean isFalse() {
          return type == QualifierType.FALSE;
      }
  
      @Override
      public boolean isSatisfiedBy(IModel model, TurboIssue issue, MetaQualifierInfo info) {
          assert type != null;
  
          // The empty qualifier is satisfied by anything
          if (isEmpty()) return true;
  
          // The false qualifier is satisfied by nothing
          if (isFalse()) return false;
  
          switch (type) {
          case ID:
              return idSatisfies(issue);
          case KEYWORD:
              return keywordSatisfies(issue, info);
          case TITLE:
              return titleSatisfies(issue);
          case DESCRIPTION:
              return bodySatisfies(issue);
          case MILESTONE:
              return milestoneSatisfies(model, issue);
          case LABEL:
              return labelsSatisfy(model, issue);
          case AUTHOR:
              return authorSatisfies(issue);
          case ASSIGNEE:
              return assigneeSatisfies(model, issue);
          case INVOLVES:
              return involvesSatisfies(model, issue);
          case TYPE:
              return typeSatisfies(issue);
          case STATE:
              return stateSatisfies(issue);
          case HAS:
              return satisfiesHasConditions(issue);
          case NO:
              return satisfiesNoConditions(issue);
          case IS:
              return satisfiesIsConditions(issue);
          case CREATED:
              return satisfiesCreationDate(issue);
          case UPDATED:
              return satisfiesUpdatedHours(issue);
          case REPO:
              return satisfiesRepo(issue);
          default:
              assert false : "Missing case for " + type;
              return false;
          }
      }
  
      @Override
      public void applyTo(TurboIssue issue, IModel model) throws QualifierApplicationException {
          assert type != null && content != null;
  
          // The empty qualifier should not be applied to anything
          assert !isEmpty();
  
          // The false qualifier should not be applied to anything
          assert !isFalse();
  
          switch (type) {
          case TITLE:
          case DESCRIPTION:
          case KEYWORD:
              throw new QualifierApplicationException("Unnecessary filter: issue text cannot be changed by dragging");
          case ID:
              throw new QualifierApplicationException("Unnecessary filter: id is immutable");
          case CREATED:
              throw new QualifierApplicationException("Unnecessary filter: cannot change issue creation date");
          case HAS:
          case NO:
          case IS:
              throw new QualifierApplicationException("Ambiguous filter: " + type);
          case MILESTONE:
              applyMilestone(issue, model);
              break;
          case LABEL:
              applyLabel(issue, model);
              break;
          case ASSIGNEE:
              applyAssignee(issue, model);
              break;
          case AUTHOR:
              throw new QualifierApplicationException("Unnecessary filter: cannot change author of issue");
          case INVOLVES:
              throw new QualifierApplicationException("Ambiguous filter: cannot change users involved with issue");
          case STATE:
              applyState(issue);
              break;
          default:
              assert false : "Missing case for " + type;
              break;
          }
      }
  
      @Override
      public boolean canBeAppliedToIssue() {
          return true;
      }
  
      @Override
      public List<QualifierType> getQualifierTypes() {
          return new ArrayList<>(Arrays.asList(type));
      }
  
      @Override
      public FilterExpression filter(Predicate<Qualifier> pred) {
          if (pred.test(this)) {
              return new Qualifier(this);
          } else {
              return EMPTY;
          }
      }
  
      @Override
      public List<Qualifier> find(Predicate<Qualifier> pred) {
          if (pred.test(this)) {
              ArrayList<Qualifier> result = new ArrayList<>();
              result.add(this);
              return result;
          } else {
              return new ArrayList<>();
          }
      }
  
      @Override
      public FilterExpression map(Function<Qualifier, Qualifier> func) {
          return func.apply(this);
      }
  
      /**
       * This method is used to serialise qualifiers. Thus whatever form returned
       * should be syntactically valid.
       */
      @Override
      public String toString() {
          if (this.isEmpty()) {
              return "";
          } else if (content.isPresent()) {
              String quotedContent = content.get();
              if (quotedContent.contains(" ")) {
                  quotedContent = "\"" + quotedContent + "\"";
              }
              if (type == QualifierType.KEYWORD) {
                  return quotedContent;
              } else {
  