From bd01eebfa57b4012bc7a7abc1aaaa1b69278b9de Mon Sep 17 00:00:00 2001 From: James Moger <james.moger@gitblit.com> Date: Sun, 19 Feb 2012 15:38:50 -0500 Subject: [PATCH] Merged issues/lucene branch --- src/com/gitblit/utils/IssueUtils.java | 823 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 files changed, 823 insertions(+), 0 deletions(-) diff --git a/src/com/gitblit/utils/IssueUtils.java b/src/com/gitblit/utils/IssueUtils.java new file mode 100644 index 0000000..eb3b347 --- /dev/null +++ b/src/com/gitblit/utils/IssueUtils.java @@ -0,0 +1,823 @@ +/* + * Copyright 2012 gitblit.com. + * + * 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.gitblit.utils; + +import java.io.IOException; +import java.text.MessageFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +import org.eclipse.jgit.JGitText; +import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException; +import org.eclipse.jgit.api.errors.JGitInternalException; +import org.eclipse.jgit.dircache.DirCache; +import org.eclipse.jgit.dircache.DirCacheBuilder; +import org.eclipse.jgit.dircache.DirCacheEntry; +import org.eclipse.jgit.lib.CommitBuilder; +import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.lib.FileMode; +import org.eclipse.jgit.lib.ObjectId; +import org.eclipse.jgit.lib.ObjectInserter; +import org.eclipse.jgit.lib.PersonIdent; +import org.eclipse.jgit.lib.RefUpdate; +import org.eclipse.jgit.lib.RefUpdate.Result; +import org.eclipse.jgit.lib.Repository; +import org.eclipse.jgit.revwalk.RevCommit; +import org.eclipse.jgit.revwalk.RevTree; +import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.treewalk.CanonicalTreeParser; +import org.eclipse.jgit.treewalk.TreeWalk; +import org.eclipse.jgit.treewalk.filter.AndTreeFilter; +import org.eclipse.jgit.treewalk.filter.PathFilterGroup; +import org.eclipse.jgit.treewalk.filter.TreeFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.gitblit.models.IssueModel; +import com.gitblit.models.IssueModel.Attachment; +import com.gitblit.models.IssueModel.Change; +import com.gitblit.models.IssueModel.Field; +import com.gitblit.models.IssueModel.Status; +import com.gitblit.models.RefModel; +import com.gitblit.utils.JsonUtils.ExcludeField; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; + +/** + * Utility class for reading Gitblit issues. + * + * @author James Moger + * + */ +public class IssueUtils { + + public static interface IssueFilter { + public abstract boolean accept(IssueModel issue); + } + + public static final String GB_ISSUES = "refs/heads/gb-issues"; + + static final Logger LOGGER = LoggerFactory.getLogger(JGitUtils.class); + + /** + * Log an error message and exception. + * + * @param t + * @param repository + * if repository is not null it MUST be the {0} parameter in the + * pattern. + * @param pattern + * @param objects + */ + private static void error(Throwable t, Repository repository, String pattern, Object... objects) { + List<Object> parameters = new ArrayList<Object>(); + if (objects != null && objects.length > 0) { + for (Object o : objects) { + parameters.add(o); + } + } + if (repository != null) { + parameters.add(0, repository.getDirectory().getAbsolutePath()); + } + LOGGER.error(MessageFormat.format(pattern, parameters.toArray()), t); + } + + /** + * Returns a RefModel for the gb-issues branch in the repository. If the + * branch can not be found, null is returned. + * + * @param repository + * @return a refmodel for the gb-issues branch or null + */ + public static RefModel getIssuesBranch(Repository repository) { + return JGitUtils.getBranch(repository, "gb-issues"); + } + + /** + * Returns all the issues in the repository. Querying issues from the + * repository requires deserializing all changes for all issues. This is an + * expensive process and not recommended. Issues should be indexed by Lucene + * and queries should be executed against that index. + * + * @param repository + * @param filter + * optional issue filter to only return matching results + * @return a list of issues + */ + public static List<IssueModel> getIssues(Repository repository, IssueFilter filter) { + List<IssueModel> list = new ArrayList<IssueModel>(); + RefModel issuesBranch = getIssuesBranch(repository); + if (issuesBranch == null) { + return list; + } + + // Collect the set of all issue paths + Set<String> issuePaths = new HashSet<String>(); + final TreeWalk tw = new TreeWalk(repository); + try { + RevCommit head = JGitUtils.getCommit(repository, GB_ISSUES); + tw.addTree(head.getTree()); + tw.setRecursive(false); + while (tw.next()) { + if (tw.getDepth() < 2 && tw.isSubtree()) { + tw.enterSubtree(); + if (tw.getDepth() == 2) { + issuePaths.add(tw.getPathString()); + } + } + } + } catch (IOException e) { + error(e, repository, "{0} failed to query issues"); + } finally { + tw.release(); + } + + // Build each issue and optionally filter out unwanted issues + + for (String issuePath : issuePaths) { + RevWalk rw = new RevWalk(repository); + try { + RevCommit start = rw.parseCommit(repository.resolve(GB_ISSUES)); + rw.markStart(start); + } catch (Exception e) { + error(e, repository, "Failed to find {1} in {0}", GB_ISSUES); + } + TreeFilter treeFilter = AndTreeFilter.create( + PathFilterGroup.createFromStrings(issuePath), TreeFilter.ANY_DIFF); + rw.setTreeFilter(treeFilter); + Iterator<RevCommit> revlog = rw.iterator(); + + List<RevCommit> commits = new ArrayList<RevCommit>(); + while (revlog.hasNext()) { + commits.add(revlog.next()); + } + + // release the revwalk + rw.release(); + + if (commits.size() == 0) { + LOGGER.warn("Failed to find changes for issue " + issuePath); + continue; + } + + // sort by commit order, first commit first + Collections.reverse(commits); + + StringBuilder sb = new StringBuilder("["); + boolean first = true; + for (RevCommit commit : commits) { + if (!first) { + sb.append(','); + } + String message = commit.getFullMessage(); + // commit message is formatted: C ISSUEID\n\nJSON + // C is an single char commit code + // ISSUEID is an SHA-1 hash + String json = message.substring(43); + sb.append(json); + first = false; + } + sb.append(']'); + + // Deserialize the JSON array as a Collection<Change>, this seems + // slightly faster than deserializing each change by itself. + Collection<Change> changes = JsonUtils.fromJsonString(sb.toString(), + new TypeToken<Collection<Change>>() { + }.getType()); + + // create an issue object form the changes + IssueModel issue = buildIssue(changes, true); + + // add the issue, conditionally, to the list + if (filter == null) { + list.add(issue); + } else { + if (filter.accept(issue)) { + list.add(issue); + } + } + } + + // sort the issues by creation + Collections.sort(list); + return list; + } + + /** + * Retrieves the specified issue from the repository with all changes + * applied to build the effective issue. + * + * @param repository + * @param issueId + * @return an issue, if it exists, otherwise null + */ + public static IssueModel getIssue(Repository repository, String issueId) { + return getIssue(repository, issueId, true); + } + + /** + * Retrieves the specified issue from the repository. + * + * @param repository + * @param issueId + * @param effective + * if true, the effective issue is built by processing comment + * changes, deletions, etc. if false, the raw issue is built + * without consideration for comment changes, deletions, etc. + * @return an issue, if it exists, otherwise null + */ + public static IssueModel getIssue(Repository repository, String issueId, boolean effective) { + RefModel issuesBranch = getIssuesBranch(repository); + if (issuesBranch == null) { + return null; + } + + if (StringUtils.isEmpty(issueId)) { + return null; + } + + String issuePath = getIssuePath(issueId); + + // Collect all changes as JSON array from commit messages + List<RevCommit> commits = JGitUtils.getRevLog(repository, GB_ISSUES, issuePath, 0, -1); + + // sort by commit order, first commit first + Collections.reverse(commits); + + StringBuilder sb = new StringBuilder("["); + boolean first = true; + for (RevCommit commit : commits) { + if (!first) { + sb.append(','); + } + String message = commit.getFullMessage(); + // commit message is formatted: C ISSUEID\n\nJSON + // C is an single char commit code + // ISSUEID is an SHA-1 hash + String json = message.substring(43); + sb.append(json); + first = false; + } + sb.append(']'); + + // Deserialize the JSON array as a Collection<Change>, this seems + // slightly faster than deserializing each change by itself. + Collection<Change> changes = JsonUtils.fromJsonString(sb.toString(), + new TypeToken<Collection<Change>>() { + }.getType()); + + // create an issue object and apply the changes to it + IssueModel issue = buildIssue(changes, effective); + return issue; + } + + /** + * Builds an issue from a set of changes. + * + * @param changes + * @param effective + * if true, the effective issue is built which accounts for + * comment changes, comment deletions, etc. if false, the raw + * issue is built. + * @return an issue + */ + private static IssueModel buildIssue(Collection<Change> changes, boolean effective) { + IssueModel issue; + if (effective) { + List<Change> effectiveChanges = new ArrayList<Change>(); + Map<String, Change> comments = new HashMap<String, Change>(); + for (Change change : changes) { + if (change.comment != null) { + if (comments.containsKey(change.comment.id)) { + Change original = comments.get(change.comment.id); + Change clone = DeepCopier.copy(original); + clone.comment.text = change.comment.text; + clone.comment.deleted = change.comment.deleted; + int idx = effectiveChanges.indexOf(original); + effectiveChanges.remove(original); + effectiveChanges.add(idx, clone); + comments.put(clone.comment.id, clone); + } else { + effectiveChanges.add(change); + comments.put(change.comment.id, change); + } + } else { + effectiveChanges.add(change); + } + } + + // effective issue + issue = new IssueModel(); + for (Change change : effectiveChanges) { + issue.applyChange(change); + } + } else { + // raw issue + issue = new IssueModel(); + for (Change change : changes) { + issue.applyChange(change); + } + } + return issue; + } + + /** + * Retrieves the specified attachment from an issue. + * + * @param repository + * @param issueId + * @param filename + * @return an attachment, if found, null otherwise + */ + public static Attachment getIssueAttachment(Repository repository, String issueId, + String filename) { + RefModel issuesBranch = getIssuesBranch(repository); + if (issuesBranch == null) { + return null; + } + + if (StringUtils.isEmpty(issueId)) { + return null; + } + + // deserialize the issue model so that we have the attachment metadata + IssueModel issue = getIssue(repository, issueId, true); + Attachment attachment = issue.getAttachment(filename); + + // attachment not found + if (attachment == null) { + return null; + } + + // retrieve the attachment content + String issuePath = getIssuePath(issueId); + RevTree tree = JGitUtils.getCommit(repository, GB_ISSUES).getTree(); + byte[] content = JGitUtils + .getByteContent(repository, tree, issuePath + "/" + attachment.id); + attachment.content = content; + attachment.size = content.length; + return attachment; + } + + /** + * Creates an issue in the gb-issues branch of the repository. The branch is + * automatically created if it does not already exist. Your change must + * include an author, summary, and description, at a minimum. If your change + * does not have those minimum requirements a RuntimeException will be + * thrown. + * + * @param repository + * @param change + * @return true if successful + */ + public static IssueModel createIssue(Repository repository, Change change) { + RefModel issuesBranch = getIssuesBranch(repository); + if (issuesBranch == null) { + JGitUtils.createOrphanBranch(repository, "gb-issues", null); + } + + if (StringUtils.isEmpty(change.author)) { + throw new RuntimeException("Must specify a change author!"); + } + if (!change.hasField(Field.Summary)) { + throw new RuntimeException("Must specify a summary!"); + } + if (!change.hasField(Field.Description)) { + throw new RuntimeException("Must specify a description!"); + } + + change.setField(Field.Reporter, change.author); + + String issueId = StringUtils.getSHA1(change.created.toString() + change.author + + change.getString(Field.Summary) + change.getField(Field.Description)); + change.setField(Field.Id, issueId); + change.code = '+'; + + boolean success = commit(repository, issueId, change); + if (success) { + return getIssue(repository, issueId, false); + } + return null; + } + + /** + * Updates an issue in the gb-issues branch of the repository. + * + * @param repository + * @param issue + * @param change + * @return true if successful + */ + public static boolean updateIssue(Repository repository, String issueId, Change change) { + boolean success = false; + RefModel issuesBranch = getIssuesBranch(repository); + + if (issuesBranch == null) { + throw new RuntimeException("gb-issues branch does not exist!"); + } + + if (change == null) { + throw new RuntimeException("change can not be null!"); + } + + if (StringUtils.isEmpty(change.author)) { + throw new RuntimeException("must specify a change author!"); + } + + // determine update code + // default update code is '=' for a general change + change.code = '='; + if (change.hasField(Field.Status)) { + Status status = Status.fromObject(change.getField(Field.Status)); + if (status.isClosed()) { + // someone closed the issue + change.code = 'x'; + } + } + success = commit(repository, issueId, change); + return success; + } + + /** + * Deletes an issue from the repository. + * + * @param repository + * @param issueId + * @return true if successful + */ + public static boolean deleteIssue(Repository repository, String issueId, String author) { + boolean success = false; + RefModel issuesBranch = getIssuesBranch(repository); + + if (issuesBranch == null) { + throw new RuntimeException("gb-issues branch does not exist!"); + } + + if (StringUtils.isEmpty(issueId)) { + throw new RuntimeException("must specify an issue id!"); + } + + String issuePath = getIssuePath(issueId); + + String message = "- " + issueId; + try { + ObjectId headId = repository.resolve(GB_ISSUES + "^{commit}"); + ObjectInserter odi = repository.newObjectInserter(); + try { + // Create the in-memory index of the new/updated issue + DirCache index = DirCache.newInCore(); + DirCacheBuilder dcBuilder = index.builder(); + // Traverse HEAD to add all other paths + TreeWalk treeWalk = new TreeWalk(repository); + int hIdx = -1; + if (headId != null) + hIdx = treeWalk.addTree(new RevWalk(repository).parseTree(headId)); + treeWalk.setRecursive(true); + while (treeWalk.next()) { + String path = treeWalk.getPathString(); + CanonicalTreeParser hTree = null; + if (hIdx != -1) + hTree = treeWalk.getTree(hIdx, CanonicalTreeParser.class); + if (!path.startsWith(issuePath)) { + // add entries from HEAD for all other paths + if (hTree != null) { + // create a new DirCacheEntry with data retrieved + // from HEAD + final DirCacheEntry dcEntry = new DirCacheEntry(path); + dcEntry.setObjectId(hTree.getEntryObjectId()); + dcEntry.setFileMode(hTree.getEntryFileMode()); + + // add to temporary in-core index + dcBuilder.add(dcEntry); + } + } + } + + // release the treewalk + treeWalk.release(); + + // finish temporary in-core index used for this commit + dcBuilder.finish(); + + ObjectId indexTreeId = index.writeTree(odi); + + // Create a commit object + PersonIdent ident = new PersonIdent(author, "gitblit@localhost"); + CommitBuilder commit = new CommitBuilder(); + commit.setAuthor(ident); + commit.setCommitter(ident); + commit.setEncoding(Constants.CHARACTER_ENCODING); + commit.setMessage(message); + commit.setParentId(headId); + commit.setTreeId(indexTreeId); + + // Insert the commit into the repository + ObjectId commitId = odi.insert(commit); + odi.flush(); + + RevWalk revWalk = new RevWalk(repository); + try { + RevCommit revCommit = revWalk.parseCommit(commitId); + RefUpdate ru = repository.updateRef(GB_ISSUES); + ru.setNewObjectId(commitId); + ru.setExpectedOldObjectId(headId); + ru.setRefLogMessage("commit: " + revCommit.getShortMessage(), false); + Result rc = ru.forceUpdate(); + switch (rc) { + case NEW: + case FORCED: + case FAST_FORWARD: + success = true; + break; + case REJECTED: + case LOCK_FAILURE: + throw new ConcurrentRefUpdateException(JGitText.get().couldNotLockHEAD, + ru.getRef(), rc); + default: + throw new JGitInternalException(MessageFormat.format( + JGitText.get().updatingRefFailed, GB_ISSUES, commitId.toString(), + rc)); + } + } finally { + revWalk.release(); + } + } finally { + odi.release(); + } + } catch (Throwable t) { + error(t, repository, "Failed to delete issue {1} to {0}", issueId); + } + return success; + } + + /** + * Changes the text of an issue comment. + * + * @param repository + * @param issue + * @param change + * the change with the comment to change + * @param author + * the author of the revision + * @param comment + * the revised comment + * @return true, if the change was successful + */ + public static boolean changeComment(Repository repository, IssueModel issue, Change change, + String author, String comment) { + Change revision = new Change(author); + revision.comment(comment); + revision.comment.id = change.comment.id; + return updateIssue(repository, issue.id, revision); + } + + /** + * Deletes a comment from an issue. + * + * @param repository + * @param issue + * @param change + * the change with the comment to delete + * @param author + * @return true, if the deletion was successful + */ + public static boolean deleteComment(Repository repository, IssueModel issue, Change change, + String author) { + Change deletion = new Change(author); + deletion.comment(change.comment.text); + deletion.comment.id = change.comment.id; + deletion.comment.deleted = true; + return updateIssue(repository, issue.id, deletion); + } + + /** + * Commit a change to the repository. Each issue is composed on changes. + * Issues are built from applying the changes in the order they were + * committed to the repository. The changes are actually specified in the + * commit messages and not in the RevTrees which allows for clean, + * distributed merging. + * + * @param repository + * @param issue + * @param change + * @return true, if the change was committed + */ + private static boolean commit(Repository repository, String issueId, Change change) { + boolean success = false; + + try { + // assign ids to new attachments + // attachments are stored by an SHA1 id + if (change.hasAttachments()) { + for (Attachment attachment : change.attachments) { + if (!ArrayUtils.isEmpty(attachment.content)) { + byte[] prefix = (change.created.toString() + change.author).getBytes(); + byte[] bytes = new byte[prefix.length + attachment.content.length]; + System.arraycopy(prefix, 0, bytes, 0, prefix.length); + System.arraycopy(attachment.content, 0, bytes, prefix.length, + attachment.content.length); + attachment.id = "attachment-" + StringUtils.getSHA1(bytes); + } + } + } + + // serialize the change as json + // exclude any attachment from json serialization + Gson gson = JsonUtils.gson(new ExcludeField( + "com.gitblit.models.IssueModel$Attachment.content")); + String json = gson.toJson(change); + + // include the json change in the commit message + String issuePath = getIssuePath(issueId); + String message = change.code + " " + issueId + "\n\n" + json; + + // Create a commit file. This is required for a proper commit and + // ensures we can retrieve the commit log of the issue path. + // + // This file is NOT serialized as part of the Change object. + switch (change.code) { + case '+': { + // New Issue. + Attachment placeholder = new Attachment("issue"); + placeholder.id = placeholder.name; + placeholder.content = "DO NOT REMOVE".getBytes(Constants.CHARACTER_ENCODING); + change.addAttachment(placeholder); + break; + } + default: { + // Update Issue. + String changeId = StringUtils.getSHA1(json); + Attachment placeholder = new Attachment("change-" + changeId); + placeholder.id = placeholder.name; + placeholder.content = "REMOVABLE".getBytes(Constants.CHARACTER_ENCODING); + change.addAttachment(placeholder); + break; + } + } + + ObjectId headId = repository.resolve(GB_ISSUES + "^{commit}"); + ObjectInserter odi = repository.newObjectInserter(); + try { + // Create the in-memory index of the new/updated issue + DirCache index = createIndex(repository, headId, issuePath, change); + ObjectId indexTreeId = index.writeTree(odi); + + // Create a commit object + PersonIdent ident = new PersonIdent(change.author, "gitblit@localhost"); + CommitBuilder commit = new CommitBuilder(); + commit.setAuthor(ident); + commit.setCommitter(ident); + commit.setEncoding(Constants.CHARACTER_ENCODING); + commit.setMessage(message); + commit.setParentId(headId); + commit.setTreeId(indexTreeId); + + // Insert the commit into the repository + ObjectId commitId = odi.insert(commit); + odi.flush(); + + RevWalk revWalk = new RevWalk(repository); + try { + RevCommit revCommit = revWalk.parseCommit(commitId); + RefUpdate ru = repository.updateRef(GB_ISSUES); + ru.setNewObjectId(commitId); + ru.setExpectedOldObjectId(headId); + ru.setRefLogMessage("commit: " + revCommit.getShortMessage(), false); + Result rc = ru.forceUpdate(); + switch (rc) { + case NEW: + case FORCED: + case FAST_FORWARD: + success = true; + break; + case REJECTED: + case LOCK_FAILURE: + throw new ConcurrentRefUpdateException(JGitText.get().couldNotLockHEAD, + ru.getRef(), rc); + default: + throw new JGitInternalException(MessageFormat.format( + JGitText.get().updatingRefFailed, GB_ISSUES, commitId.toString(), + rc)); + } + } finally { + revWalk.release(); + } + } finally { + odi.release(); + } + } catch (Throwable t) { + error(t, repository, "Failed to commit issue {1} to {0}", issueId); + } + return success; + } + + /** + * Returns the issue path. This follows the same scheme as Git's object + * store path where the first two characters of the hash id are the root + * folder with the remaining characters as a subfolder within that folder. + * + * @param issueId + * @return the root path of the issue content on the gb-issues branch + */ + static String getIssuePath(String issueId) { + return issueId.substring(0, 2) + "/" + issueId.substring(2); + } + + /** + * Creates an in-memory index of the issue change. + * + * @param repo + * @param headId + * @param change + * @return an in-memory index + * @throws IOException + */ + private static DirCache createIndex(Repository repo, ObjectId headId, String issuePath, + Change change) throws IOException { + + DirCache inCoreIndex = DirCache.newInCore(); + DirCacheBuilder dcBuilder = inCoreIndex.builder(); + ObjectInserter inserter = repo.newObjectInserter(); + + Set<String> ignorePaths = new TreeSet<String>(); + try { + // Add any attachments to the temporary index + if (change.hasAttachments()) { + for (Attachment attachment : change.attachments) { + // build a path name for the attachment and mark as ignored + String path = issuePath + "/" + attachment.id; + ignorePaths.add(path); + + // create an index entry for this attachment + final DirCacheEntry dcEntry = new DirCacheEntry(path); + dcEntry.setLength(attachment.content.length); + dcEntry.setLastModified(change.created.getTime()); + dcEntry.setFileMode(FileMode.REGULAR_FILE); + + // insert object + dcEntry.setObjectId(inserter.insert(Constants.OBJ_BLOB, attachment.content)); + + // add to temporary in-core index + dcBuilder.add(dcEntry); + } + } + + // Traverse HEAD to add all other paths + TreeWalk treeWalk = new TreeWalk(repo); + int hIdx = -1; + if (headId != null) + hIdx = treeWalk.addTree(new RevWalk(repo).parseTree(headId)); + treeWalk.setRecursive(true); + + while (treeWalk.next()) { + String path = treeWalk.getPathString(); + CanonicalTreeParser hTree = null; + if (hIdx != -1) + hTree = treeWalk.getTree(hIdx, CanonicalTreeParser.class); + if (!ignorePaths.contains(path)) { + // add entries from HEAD for all other paths + if (hTree != null) { + // create a new DirCacheEntry with data retrieved from + // HEAD + final DirCacheEntry dcEntry = new DirCacheEntry(path); + dcEntry.setObjectId(hTree.getEntryObjectId()); + dcEntry.setFileMode(hTree.getEntryFileMode()); + + // add to temporary in-core index + dcBuilder.add(dcEntry); + } + } + } + + // release the treewalk + treeWalk.release(); + + // finish temporary in-core index used for this commit + dcBuilder.finish(); + } finally { + inserter.release(); + } + return inCoreIndex; + } +} \ No newline at end of file -- Gitblit v1.9.1