/* * Copyright 2015 Igor Maznitsa. * * 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.igormaznitsa.mindmap.model; import java.io.File; import com.igormaznitsa.mindmap.model.logger.Logger; import com.igormaznitsa.mindmap.model.logger.LoggerFactory; import java.io.IOException; import java.io.Serializable; import java.io.Writer; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.EnumMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Pattern; import javax.annotation.Nonnull; import javax.annotation.Nullable; import com.igormaznitsa.meta.annotation.MayContainNull; import com.igormaznitsa.meta.annotation.MustNotContainNull; import com.igormaznitsa.meta.common.utils.Assertions; import com.igormaznitsa.meta.common.utils.GetUtils; import com.igormaznitsa.mindmap.model.parser.MindMapLexer; public final class Topic implements Serializable, Constants, Iterable<Topic> { private static final long serialVersionUID = -4642569244907433215L; private static Logger logger = LoggerFactory.getLogger(Topic.class); private static final AtomicLong LOCALUID_GENERATOR = new AtomicLong(); @Nullable private Topic parent; private final EnumMap<Extra.ExtraType, Extra<?>> extras = new EnumMap<Extra.ExtraType, Extra<?>>(Extra.ExtraType.class); private final Map<Extra.ExtraType, Extra<?>> unmodifableExtras = Collections.unmodifiableMap(this.extras); private final Map<String, String> attributes = new TreeMap<String, String>(ModelUtils.STRING_COMPARATOR); private final Map<String, String> unmodifableAttributes = Collections.unmodifiableMap(this.attributes); private final Map<String, String> codeSnippets = new TreeMap<String, String>(ModelUtils.STRING_COMPARATOR); private final Map<String, String> unmodifableCodeSnippets = Collections.unmodifiableMap(this.codeSnippets); @Nonnull private volatile String text; @Nonnull private final List<Topic> children = new ArrayList<Topic>(); @Nonnull private final List<Topic> unmodifableChildren = Collections.unmodifiableList(this.children); @Nullable private transient Object payload; private final transient long localUID = LOCALUID_GENERATOR.getAndIncrement(); @Nonnull private final MindMap map; /** * Constructor to build topic on base of another topic for another mind map. * * @param mindMap mind map to be owner for new topic * @param base base souce topic * @param copyChildren flag to make copy of children, true if to make copy, * false otherwise * * @since 1.2.2 */ public Topic(@Nonnull final MindMap mindMap, @Nonnull final Topic base, final boolean copyChildren) { this(mindMap, base.text); this.attributes.putAll(base.attributes); this.extras.putAll(base.extras); this.codeSnippets.putAll(base.codeSnippets); if (copyChildren) { for (final Topic t : base.children) { final Topic clonedChildren = new Topic(mindMap, t, true); clonedChildren.parent = this; this.children.add(clonedChildren); } } } public Topic(@Nonnull final MindMap map, @Nullable final Topic parent, @Nonnull final String text, @Nonnull @MayContainNull final Extra<?>... extras) { this(map, text, extras); this.parent = parent; if (parent != null) { if (parent.getMap() != map) { throw new IllegalArgumentException("Parent must belong to the same mind map"); } parent.children.add(this); } } private Topic(@Nonnull final MindMap map, @Nonnull final String text, @Nonnull @MayContainNull final Extra<?>... extras) { this.map = Assertions.assertNotNull(map); this.text = Assertions.assertNotNull(text); for (final Extra<?> e : extras) { if (e != null) { this.extras.put(e.getType(), e); } } } public boolean containTopic(@Nonnull final Topic topic) { boolean result = false; if (this == topic) { result = true; } else { for (final Topic t : this.children) { if (t.containTopic(topic)) { result = true; break; } } } return result; } @Nullable public Topic nextSibling() { final int position = this.parent == null ? -1 : this.parent.getChildren().indexOf(this); final Topic result; if (position < 0) { result = null; } else { final List<Topic> all = this.parent.getChildren(); final int nextPosition = position + 1; result = all.size() > nextPosition ? all.get(nextPosition) : null; } return result; } @Nullable public Topic prevSibling() { final int position = this.parent == null ? -1 : this.parent.getChildren().indexOf(this); final Topic result; if (position <= 0) { result = null; } else { final List<Topic> all = this.parent.getChildren(); result = all.get(position - 1); } return result; } public boolean containsPattern(final @Nullable File baseFolder, final @Nonnull Pattern pattern, final boolean findInTopicText, @Nullable final Set<Extra.ExtraType> extrasForSearch) { boolean result = false; if (findInTopicText && pattern.matcher(this.text).find()) { result = true; } else if (extrasForSearch != null && !extrasForSearch.isEmpty()) { for (final Extra<?> e : this.extras.values()) { if (extrasForSearch.contains(e.getType()) && e.containsPattern(baseFolder, pattern)) { result = true; break; } } } return result; } public boolean isRoot() { return this.parent == null; } @Nullable public Object getPayload() { return this.payload; } public void setPayload(@Nullable final Object value) { this.payload = value; } @Nonnull private Object readResolve() { return new Topic(this.map, this, true); } @Nonnull public MindMap getMap() { return this.map; } public int getTopicLevel() { Topic topic = this.parent; int result = 0; while (topic != null) { topic = topic.parent; result++; } return result; } @Nullable public Topic findParentForDepth(int depth) { this.map.lock(); try { Topic result = this.parent; while (depth > 0 && result != null) { result = result.parent; depth--; } return result; } finally { this.map.unlock(); } } @Nonnull public Topic getRoot() { this.map.lock(); try { Topic result = this; while (true) { final Topic prev = result.parent; if (prev == null) { break; } result = prev; } return result; } finally { this.map.unlock(); } } private boolean canBeDeletedSilently() { final MindMapController controller = this.map.getController(); if (controller != null) { return controller.canBeDeletedSilently(this.map, this); } return false; } public boolean canBeLost() { this.map.lock(); try { boolean noImportantContent = this.text.trim().isEmpty() && this.extras.isEmpty() && canBeDeletedSilently(); if (noImportantContent) { for (final Topic t : this.children) { noImportantContent &= t.canBeLost(); if (!noImportantContent) { break; } } } return noImportantContent; } finally { this.map.unlock(); } } @Nullable public static Topic parse(@Nonnull final MindMap map, @Nonnull final MindMapLexer lexer) throws IOException { map.lock(); try { Topic topic = null; int depth = 0; Extra.ExtraType extraType = null; String codeSnippetlanguage = null; String codeSnippetBody = null; int detectedLevel = -1; while (true) { final int oldLexerPosition = lexer.getCurrentPosition().getOffset(); lexer.advance(); final boolean lexerPositionWasNotChanged = oldLexerPosition == lexer.getCurrentPosition().getOffset(); final MindMapLexer.TokenType token = lexer.getTokenType(); if (token == null || lexerPositionWasNotChanged) { break; } switch (token) { case TOPIC_LEVEL: { final String tokenText = lexer.getTokenText(); detectedLevel = ModelUtils.calcCharsOnStart('#', tokenText); }break; case TOPIC_TITLE: { final String tokenText = ModelUtils.removeISOControls(lexer.getTokenText()); final String newTopicText = ModelUtils.unescapeMarkdownStr(tokenText); if (detectedLevel == depth + 1) { depth = detectedLevel; topic = new Topic(map, topic, newTopicText); } else if (detectedLevel == depth) { topic = new Topic(map, topic == null ? null : topic.getParent(), newTopicText); } else if (detectedLevel < depth) { if (topic != null) { topic = topic.findParentForDepth(depth - detectedLevel); topic = new Topic(map, topic, newTopicText); depth = detectedLevel; } } } break; case EXTRA_TYPE: { final String extraName = lexer.getTokenText().substring(1).trim(); try { extraType = Extra.ExtraType.valueOf(extraName); } catch (IllegalArgumentException ex) { extraType = null; } } break; case CODE_SNIPPET_START: { if (topic != null) { codeSnippetlanguage = lexer.getTokenText().substring(3); codeSnippetBody = ""; } } break; case CODE_SNIPPET_BODY: { codeSnippetBody += lexer.getTokenText(); } break; case CODE_SNIPPET_END: { if (topic != null && codeSnippetlanguage != null && codeSnippetBody != null) { topic.codeSnippets.put(codeSnippetlanguage.trim(), codeSnippetBody); } codeSnippetlanguage = null; codeSnippetBody = null; } break; case ATTRIBUTE: { if (topic != null) { final String text = lexer.getTokenText().trim(); MindMap.fillMapByAttributes(text, topic.attributes); } extraType = null; } break; case EXTRA_TEXT: { if (topic != null && extraType != null) { try { final String text = lexer.getTokenText(); final String groupPre = extraType.preprocessString(text.substring(5, text.length() - 6)); if (groupPre != null) { topic.setExtra(extraType.parseLoaded(groupPre)); } else { logger.error("Detected invalid extra data " + extraType); } } catch (Exception ex) { logger.error("Unexpected exception #23241", ex); //NOI18N } finally { extraType = null; } } } break; case UNKNOWN_LINE: { if (topic != null && extraType != null) { extraType = null; } } break; default: break; } } return topic == null ? null : topic.getRoot(); } finally { map.unlock(); } } @Nullable public Topic getFirst() { return this.children.isEmpty() ? null : this.children.get(0); } @Nullable public Topic getLast() { return this.children.isEmpty() ? null : this.children.get(this.children.size() - 1); } @Nonnull @MustNotContainNull public List<Topic> getChildren() { return this.unmodifableChildren; } public int getNumberOfExtras() { return this.extras.size(); } @Nonnull public Map<Extra.ExtraType, Extra<?>> getExtras() { return this.unmodifableExtras; } @Nonnull @MustNotContainNull public Extra<?>[] extrasToArray() { final Collection<Extra<?>> collection = this.unmodifableExtras.values(); return collection.toArray(new Extra<?>[collection.size()]); } @Nonnull public Map<String, String> getAttributes() { return this.unmodifableAttributes; } @Nonnull public Map<String, String> getCodeSnippets() { return this.unmodifableCodeSnippets; } public boolean setAttribute(@Nonnull final String name, @Nullable final String value) { this.map.lock(); try { if (value == null) { return this.attributes.remove(name) != null; } else { return !value.equals(this.attributes.put(name, value)); } } finally { this.map.unlock(); } } public boolean setCodeSnippet(@Nonnull final String language, @Nullable final String text) { this.map.lock(); try { if (text == null) { return this.codeSnippets.remove(language) != null; } else { return !text.equals(this.codeSnippets.put(language, text)); } } finally { this.map.unlock(); } } @Nullable public String getCodeSnippet(@Nonnull final String language) { return this.codeSnippets.get(language); } @Nullable public String getAttribute(@Nonnull final String name) { return this.attributes.get(name); } public void delete() { this.map.lock(); try { final Topic theParent = this.parent; if (theParent != null) { theParent.children.remove(this); } } finally { this.map.unlock(); } } @Nullable public Topic getParent() { return this.parent; } @Nonnull public String getText() { return this.text; } public boolean isFirstChild(@Nonnull final Topic t) { return !this.children.isEmpty() && this.children.get(0) == t; } public boolean isLastChild(@Nonnull final Topic t) { return !this.children.isEmpty() && this.children.get(this.children.size() - 1) == t; } public void setText(@Nonnull final String text) { this.map.lock(); try { this.text = Assertions.assertNotNull(text); } finally { this.map.unlock(); } } public boolean removeExtra(@Nonnull @MustNotContainNull final Extra.ExtraType... types) { this.map.lock(); try { boolean result = false; for (final Extra.ExtraType e : Assertions.assertDoesntContainNull(types)) { result |= this.extras.remove(e) != null; } return result; } finally { this.map.unlock(); } } public void setExtra(@MustNotContainNull @Nonnull final Extra<?>... extras) { this.map.lock(); try { for (final Extra<?> e : Assertions.assertDoesntContainNull(extras)) { this.extras.put(e.getType(), e); } } finally { this.map.unlock(); } } public boolean makeFirst() { this.map.lock(); try { final Topic theParent = this.parent; if (theParent != null) { int thatIndex = theParent.children.indexOf(this); if (thatIndex > 0) { theParent.children.remove(thatIndex); theParent.children.add(0, this); return true; } } return false; } finally { this.map.unlock(); } } public boolean hasAncestor(@Nonnull final Topic topic) { Topic parent = this.parent; while (parent != null) { if (parent == topic) { return true; } parent = parent.getParent(); } return false; } public boolean makeLast() { this.map.lock(); try { final Topic theParent = this.parent; if (theParent != null) { int thatIndex = theParent.children.indexOf(this); if (thatIndex >= 0 && thatIndex != theParent.children.size() - 1) { theParent.children.remove(thatIndex); theParent.children.add(this); return true; } } return false; } finally { this.map.unlock(); } } public void moveBefore(@Nonnull final Topic topic) { this.map.lock(); try { final Topic theParent = this.parent; if (theParent != null) { int thatIndex = theParent.children.indexOf(topic); final int thisIndex = theParent.children.indexOf(this); if (thatIndex > thisIndex) { thatIndex--; } if (thatIndex >= 0 && thisIndex >= 0) { theParent.children.remove(this); theParent.children.add(thatIndex, this); } } } finally { this.map.unlock(); } } @Nullable public String findAttributeInAncestors(@Nonnull final String attrName) { this.map.lock(); try { String result = null; Topic current = this.parent; while (result == null && current != null) { result = current.getAttribute(attrName); current = current.parent; } return result; } finally { this.map.unlock(); } } public void moveAfter(@Nonnull final Topic topic) { this.map.lock(); try { final Topic theParent = this.parent; if (theParent != null) { int thatIndex = theParent.children.indexOf(topic); int thisIndex = theParent.children.indexOf(this); if (thatIndex > thisIndex) { thatIndex--; } if (thatIndex >= 0 && thisIndex >= 0) { theParent.children.remove(this); theParent.children.add(thatIndex + 1, this); } } } finally { this.map.unlock(); } } public void write(@Nonnull final Writer out) throws IOException { this.map.lock(); try { write(1, out); } finally { this.map.unlock(); } } private void write(final int level, @Nonnull final Writer out) throws IOException { out.append(NEXT_LINE); ModelUtils.writeChar(out, '#', level); out.append(' ').append(ModelUtils.escapeMarkdownStr(this.text)).append(NEXT_LINE); if (!this.attributes.isEmpty()) { out.append("> ").append(MindMap.allAttributesAsString(this.attributes)).append(NEXT_LINE).append(NEXT_LINE); //NOI18N } for (final Map.Entry<Extra.ExtraType, Extra<?>> e : this.extras.entrySet()) { e.getValue().write(out); out.append(NEXT_LINE); } if (!this.codeSnippets.isEmpty()) { for (final Map.Entry<String, String> e : this.codeSnippets.entrySet()) { final String language = e.getKey(); final String body = e.getValue(); out.append("```").append(language).append(NEXT_LINE); out.append(body); if (!body.endsWith("\n")) { out.append(NEXT_LINE); } out.append("```").append(NEXT_LINE); } } for (final Topic t : this.children) { t.write(level + 1, out); } } @Override public int hashCode() { return (int) ((this.localUID >>> 32) ^ (this.localUID & 0xFFFFFFFFL)); } @Override public boolean equals(@Nonnull final Object topic) { if (this == topic) { return true; } if (topic instanceof Topic) { return this.localUID == ((Topic) topic).localUID; } return false; } @Override @Nonnull public String toString() { return "MindMapTopic('" + this.text + "')"; //NOI18N } public long getLocalUid() { return this.localUID; } public boolean hasChildren() { this.map.lock(); try { return !this.children.isEmpty(); } finally { this.map.unlock(); } } boolean removeAllLinksTo(@Nullable final Topic topic) { boolean result = false; if (topic != null) { final String uid = topic.getAttribute(ExtraTopic.TOPIC_UID_ATTR); if (uid != null) { final ExtraTopic link = (ExtraTopic) this.getExtras().get(Extra.ExtraType.TOPIC); if (link != null && uid.equals(link.getValue())) { this.removeExtra(Extra.ExtraType.TOPIC); result = true; } } for (final Topic ch : this.children) { result |= ch.removeAllLinksTo(topic); } } return result; } boolean removeTopic(@Nullable final Topic topic) { if (topic == null) { return false; } final Iterator<Topic> iterator = this.children.iterator(); while (iterator.hasNext()) { final Topic t = iterator.next(); if (t == topic) { iterator.remove(); return true; } else if (t.removeTopic(topic)) { return true; } } return false; } public void removeAllChildren() { this.children.clear(); } public boolean moveToNewParent(@Nullable final Topic newParent) { this.map.lock(); try { if (newParent == null || this == newParent || this.getParent() == newParent || this.children.contains(newParent)) { return false; } final Topic theParent = this.parent; if (theParent != null) { theParent.children.remove(this); } newParent.children.add(this); this.parent = newParent; return true; } finally { this.map.unlock(); } } @Nonnull public Topic makeChild(@Nullable final String text, @Nullable final Topic afterTheTopic) { this.map.lock(); try { final Topic result = new Topic(this.map, this, GetUtils.ensureNonNull(text, "")); //NOI18N if (afterTheTopic != null && this.children.indexOf(afterTheTopic) >= 0) { result.moveAfter(afterTheTopic); } return result; } finally { this.map.unlock(); } } @Nullable public Topic findNext(@Nullable final TopicChecker checker) { this.map.lock(); try { Topic result = null; Topic current = this.getParent(); if (current != null) { final int indexThis = current.children.indexOf(this); if (indexThis >= 0) { for (int i = indexThis + 1; i < current.children.size(); i++) { if (checker == null) { result = current.children.get(i); break; } else if (checker.check(current.children.get(i))) { result = current.children.get(i); break; } } } } return result; } finally { this.map.unlock(); } } @Nullable public Topic findPrev(@Nonnull final TopicChecker checker) { this.map.lock(); try { Topic result = null; Topic current = this.getParent(); if (current != null) { final int indexThis = current.children.indexOf(this); if (indexThis >= 0) { for (int i = indexThis - 1; i >= 0; i--) { if (checker.check(current.children.get(i))) { result = current.children.get(i); break; } } } } return result; } finally { this.map.unlock(); } } public void removeExtras(@Nullable @MayContainNull final Extra<?>... extras) { this.map.lock(); try { if (extras == null || extras.length == 0) { this.extras.clear(); } else { for (final Extra<?> e : extras) { if (e != null) { this.extras.remove(e.getType()); } } } } finally { this.map.unlock(); } } @Nullable public Topic findForAttribute(@Nonnull final String attrName, @Nonnull String value) { if (value.equals(this.getAttribute(attrName))) { return this; } Topic result = null; for (final Topic c : this.children) { result = c.findForAttribute(attrName, value); if (result != null) { break; } } return result; } @Nonnull public int[] getPositionPath() { final Topic[] path = getPath(); final int[] result = new int[path.length]; Topic current = path[0]; int index = 1; while (index < path.length) { final Topic next = path[index]; final int theindex = current.children.indexOf(next); result[index++] = theindex; if (theindex < 0) { break; } current = next; } return result; } @Nonnull @MustNotContainNull public Topic[] getPath() { final List<Topic> list = new ArrayList<Topic>(); Topic current = this; do { list.add(0, current); current = current.parent; } while (current != null); return list.toArray(new Topic[list.size()]); } @Nonnull Topic makeCopy(@Nonnull final MindMap newMindMap, @Nullable final Topic parent) { this.map.lock(); try { final Topic result = new Topic(newMindMap, parent, this.text, this.extras.values().toArray(new Extra<?>[this.extras.values().size()])); for (final Topic c : this.children) { c.makeCopy(newMindMap, result); } result.attributes.putAll(this.attributes); result.codeSnippets.putAll(this.codeSnippets); return result; } finally { this.map.unlock(); } } public boolean removeExtraFromSubtree(@Nonnull @MustNotContainNull final Extra.ExtraType... type) { boolean result = false; this.map.lock(); try { for (final Extra.ExtraType t : type) { result |= this.extras.remove(t) != null; } for (final Topic c : this.children) { result |= c.removeExtraFromSubtree(type); } return result; } finally { this.map.unlock(); } } public boolean removeAttributeFromSubtree(@Nonnull @MustNotContainNull final String... names) { boolean result = false; this.map.lock(); try { for (final String t : names) { result |= this.attributes.remove(t) != null; } for (final Topic c : this.children) { result |= c.removeAttributeFromSubtree(names); } return result; } finally { this.map.unlock(); } } public boolean deleteLinkToFileIfPresented(@Nonnull final File baseFolder, @Nonnull final MMapURI file) { boolean result = false; if (this.extras.containsKey(Extra.ExtraType.FILE)) { final ExtraFile fileLink = (ExtraFile) this.extras.get(Extra.ExtraType.FILE); if (fileLink.isSameOrHasParent(baseFolder, file)) { result = this.extras.remove(Extra.ExtraType.FILE) != null; } } for (final Topic c : this.children) { result |= c.deleteLinkToFileIfPresented(baseFolder, file); } return result; } public boolean replaceLinkToFileIfPresented(@Nonnull final File baseFolder, @Nonnull final MMapURI oldFile, @Nonnull final MMapURI newFile) { boolean result = false; if (this.extras.containsKey(Extra.ExtraType.FILE)) { final ExtraFile fileLink = (ExtraFile) this.extras.get(Extra.ExtraType.FILE); final ExtraFile replacement; if (fileLink.isSame(baseFolder, oldFile)) { replacement = new ExtraFile(newFile); } else { replacement = fileLink.replaceParentPath(baseFolder, oldFile, newFile); } if (replacement != null) { result = true; this.extras.remove(Extra.ExtraType.FILE); this.extras.put(Extra.ExtraType.FILE, replacement); } } for (final Topic c : this.children) { result |= c.replaceLinkToFileIfPresented(baseFolder, oldFile, newFile); } return result; } public boolean doesContainFileLink(@Nonnull final File baseFolder, @Nonnull final MMapURI file) { if (this.extras.containsKey(Extra.ExtraType.FILE)) { final ExtraFile fileLink = (ExtraFile) this.extras.get(Extra.ExtraType.FILE); if (fileLink.isSame(baseFolder, file)) { return true; } } for (final Topic c : this.children) { if (c.doesContainFileLink(baseFolder, file)) { return true; } } return false; } @Override @Nonnull public Iterator<Topic> iterator() { final Iterator<Topic> iter = this.children.iterator(); return new Iterator<Topic>() { Topic childTopic; Iterator<Topic> childIterator; @Override public void remove() { iter.remove(); } @Nonnull Iterator<Topic> init() { if (iter.hasNext()) { this.childTopic = iter.next(); } return this; } @Override public boolean hasNext() { return iter.hasNext() || this.childTopic != null || (this.childIterator != null && this.childIterator.hasNext()); } @Nonnull @Override public Topic next() { final Topic result; if (this.childTopic != null) { result = this.childTopic; this.childTopic = null; this.childIterator = result.iterator(); } else if (this.childIterator != null) { if (this.childIterator.hasNext()) { result = this.childIterator.next(); } else { result = iter.next(); this.childIterator = result.iterator(); } } else { throw new NoSuchElementException(); } return result; } }.init(); } /** * Check that the topic contains any code snipet for language from array (case sensitive). * @param languageNames names of language * @return true if code snippet is detected for any language, false otherwise * * @since 1.3.1 */ public boolean doesContainCodeSnippetForAnyLanguage(@Nonnull @MustNotContainNull String ... languageNames) { boolean result = false; if (!this.codeSnippets.isEmpty()){ for(final String s : languageNames){ if (this.codeSnippets.containsKey(s)){ result = true; break; } } } return result; } }