public void OnDeleteFile(File file) { if (file.Type == FileType.Image) { // We need to get the image links affected by this CodeDB.Accessor accessor = EngineInstance.CodeDB.GetAccessor(); try { accessor.GetReadOnlyLock(); IDObjects.NumberSet linksAffected = accessor.GetImageLinkIDsByTarget(file.ID, Delegates.NeverCancel); accessor.ReleaseLock(); unprocessedChanges.DeleteImageFile((ImageFile)file, linksAffected); } finally { if (accessor.HasLock) { accessor.ReleaseLock(); } accessor.Dispose(); } } }
public void OnFileChanged(File file) { if (file.Type == FileType.Style) { // Add it to the build list. The build function will check if it's part of a style we're using. unprocessedChanges.AddStyleFile(file.ID); } else if (file.Type == FileType.Image) { // Add it to the build list. The build function will check if it's used or unused. unprocessedChanges.AddImageFile(file.ID); // Also rebuild the HTML files containing links to it in case the dimensions changed. // Creating and destroying an accessor on every event might be a heavy operation. This event shouldn't // happen too often though so it's probably not worth caching one, plus it would be difficult to know when // to release it. CodeDB.Accessor accessor = EngineInstance.CodeDB.GetAccessor(); try { accessor.GetReadOnlyLock(); List <ImageLink> imageLinks = accessor.GetImageLinksByTarget(file.ID, Delegates.NeverCancel, CodeDB.Accessor.GetImageLinkFlags.DontLookupClasses); foreach (var imageLink in imageLinks) { unprocessedChanges.AddSourceFile(imageLink.FileID); if (imageLink.ClassID != 0) { unprocessedChanges.AddClass(imageLink.ClassID); } } accessor.ReleaseLock(); } finally { if (accessor.HasLock) { accessor.ReleaseLock(); } accessor.Dispose(); } } // We don't care about source files here. They'll be handled by functions like OnUpdateTopic(). }
/* Function: ProcessDeletedFile * Takes a deleted <File> and updates <CodeDB.Manager>. The <CodeDB.Accessor> should NOT already hold a lock. */ protected ProcessFileResult ProcessDeletedFile(File file, CodeDB.Accessor codeDBAccessor, CancelDelegate cancelDelegate) { if (file.Type == FileType.Source) { return(ProcessDeletedSourceFile(file, codeDBAccessor, cancelDelegate)); } // Style and image files are only processed by output targets. They're not in CodeDB so we don't need to do anything here. else { return(ProcessFileResult.Success); } }
// Group: Functions // __________________________________________________________________________ /* Function: BuildSourceFile * Builds an output file based on a source file. The accessor should NOT hold a lock on the database. This will also * build the metadata files. */ protected void BuildSourceFile(int fileID, CodeDB.Accessor accessor, CancelDelegate cancelDelegate) { #if DEBUG if (accessor.LockHeld != CodeDB.Accessor.LockType.None) { throw new Exception("Shouldn't call BuildSourceFile() when the accessor already holds a database lock."); } #endif Components.HTMLTopicPages.File page = new Components.HTMLTopicPages.File(this, fileID); bool hasTopics = page.Build(accessor, cancelDelegate); if (cancelDelegate()) { return; } if (hasTopics) { lock (accessLock) { if (buildState.SourceFilesWithContent.Add(fileID) == true) { buildState.NeedToBuildMenu = true;; } } } else { DeleteOutputFileIfExists(page.OutputFile); DeleteOutputFileIfExists(page.ToolTipsFile); DeleteOutputFileIfExists(page.SummaryFile); DeleteOutputFileIfExists(page.SummaryToolTipsFile); lock (accessLock) { if (buildState.SourceFilesWithContent.Remove(fileID) == true) { buildState.NeedToBuildMenu = true; } } } }
/* Function: GetLinks * * Retrieves the <Links> appearing in the file. * * If the <CodeDB.Accessor> doesn't have a lock this function will acquire and release a read-only lock. * If it already has a lock it will use it and not release it. */ public override List <Link> GetLinks(CodeDB.Accessor accessor, CancelDelegate cancelDelegate) { bool releaseLock = false; if (accessor.LockHeld == CodeDB.Accessor.LockType.None) { accessor.GetReadOnlyLock(); releaseLock = true; } try { return(accessor.GetLinksInFile(fileID, cancelDelegate)); } finally { if (releaseLock) { accessor.ReleaseLock(); } } }
/* Function: ProcessDeletedSourceFile * Takes a deleted <File> and updates <CodeDB.Manager>. The <CodeDB.Accessor> should NOT already hold a lock. */ protected ProcessFileResult ProcessDeletedSourceFile(File file, CodeDB.Accessor codeDBAccessor, CancelDelegate cancelDelegate) { codeDBAccessor.GetReadPossibleWriteLock(); try { codeDBAccessor.DeleteTopicsInFile(file.ID, cancelDelegate); codeDBAccessor.DeleteLinksInFile(file.ID, cancelDelegate); codeDBAccessor.DeleteImageLinksInFile(file.ID, cancelDelegate); } finally { codeDBAccessor.ReleaseLock(); } // Need this check in case CodeDB quit early because of the cancel delegate. if (cancelDelegate()) { return(ProcessFileResult.Cancelled); } return(ProcessFileResult.Success); }
// Group: Prefix Data File Functions // __________________________________________________________________________ /* Function: BuildPrefixDataFile */ public void BuildPrefixDataFile(string prefix, CodeDB.Accessor accessor, CancelDelegate cancelDelegate) { var keywordEntries = GetPrefixKeywords(prefix, accessor, cancelDelegate); if (keywordEntries == null || keywordEntries.Count == 0) { HTMLBuilder.DeleteOutputFileIfExists(HTMLBuilder.SearchIndex_PrefixDataFile(prefix)); return; } SortKeywordEntries(keywordEntries); foreach (var keywordEntry in keywordEntries) { SortTopicEntries(keywordEntry); RemoveDuplicateTopics(keywordEntry); } BuildPrefixDataFileJS(prefix, keywordEntries); Path path = HTMLBuilder.SearchIndex_PrefixDataFile(prefix); try { // This will create multiple subdirectories if needed, and will not throw an exception if it already exists. // We can't use SearchIndex_DataFolder because we may need a subfolder of it. System.IO.Directory.CreateDirectory(path.ParentFolder); } catch (Exception e) { throw new Exceptions.UserFriendly( Locale.Get("NaturalDocs.Engine", "Error.CouldNotCreateOutputFolder(name, exception)", path.ParentFolder, e.Message) ); } System.IO.File.WriteAllText(path, output.ToString()); }
/* Function: ProcessDeletedFile * Takes a deleted <File> retrieved using <ClaimDeletedFile()> and updates <CodeDB.Manager>. Returns the result code that * should be passed to <ReleaseClaimedFile()> if it was retrieved by <ClaimDeletedFile()>. The <CodeDB.Accessor> should NOT * already hold a lock. */ public ReleaseClaimedFileReason ProcessDeletedFile(File file, CodeDB.Accessor codeDBAccessor, CancelDelegate cancelDelegate) { // Source files if (file.Type == FileType.Source) { codeDBAccessor.GetReadPossibleWriteLock(); try { codeDBAccessor.DeleteTopicsInFile(file.ID, cancelDelegate); codeDBAccessor.DeleteLinksInFile(file.ID, cancelDelegate); codeDBAccessor.DeleteImageLinksInFile(file.ID, cancelDelegate); } finally { codeDBAccessor.ReleaseLock(); } // Need this check in case CodeDB quit early because of the cancel delegate. if (cancelDelegate()) { return(ReleaseClaimedFileReason.CancelledProcessing); } else { return(ReleaseClaimedFileReason.SuccessfullyProcessed); } } // Style and image files else { // These are only processed by output builders. They're not in CodeDB so we don't need to do anything here. return(ReleaseClaimedFileReason.SuccessfullyProcessed); } }
// Group: Abstract Functions // __________________________________________________________________________ /* Function: GetTopics * * Retrieves the <Topics> for the page's location. * * When implementing this function note that the <CodeDB.Accessor> may or may not already have a lock. */ public abstract List <Topic> GetTopics(CodeDB.Accessor accessor, CancelDelegate cancelDelegate);
// Group: Functions // __________________________________________________________________________ /* Function: BuildMenu */ protected void BuildMenu(CodeDB.Accessor accessor, CancelDelegate cancelDelegate) { JSMenuData jsMenuData = new JSMenuData(this); // Build file menu lock (accessLock) { foreach (int fileID in buildState.SourceFilesWithContent) { if (cancelDelegate()) { return; } jsMenuData.AddFile(EngineInstance.Files.FromID(fileID)); } } // Build class and database menu List <KeyValuePair <int, ClassString> > classes = null; accessor.GetReadOnlyLock(); try { classes = accessor.GetClassesByID(buildState.ClassFilesWithContent, cancelDelegate); } finally { accessor.ReleaseLock(); } foreach (KeyValuePair <int, ClassString> classEntry in classes) { if (cancelDelegate()) { return; } jsMenuData.AddClass(classEntry.Value); } // Condense, sort, and build jsMenuData.Condense(); if (cancelDelegate()) { return; } jsMenuData.Sort(); if (cancelDelegate()) { return; } StringTable <IDObjects.NumberSet> newMenuDataFiles = jsMenuData.Build(); if (cancelDelegate()) { return; } // Clear out any old menu files that are no longer in use. lock (accessLock) { foreach (var usedMenuDataFileInfo in buildState.UsedMenuDataFiles) { string type = usedMenuDataFileInfo.Key; IDObjects.NumberSet oldNumbers = usedMenuDataFileInfo.Value; IDObjects.NumberSet newNumbers = newMenuDataFiles[type]; if (newNumbers != null) { // It's okay that we're altering the original NumberSet, we're throwing it out later. oldNumbers.Remove(newNumbers); } foreach (int oldNumber in oldNumbers) { try { System.IO.File.Delete(Menu_DataFile(type, oldNumber)); } catch (Exception e) { if (!(e is System.IO.IOException || e is System.IO.DirectoryNotFoundException)) { throw; } } } } buildState.UsedMenuDataFiles = newMenuDataFiles; } }
/* Function: BuildSearchPrefixDataFile */ protected void BuildSearchPrefixDataFile(string prefix, CodeDB.Accessor accessor, CancelDelegate cancelDelegate) { Components.JSSearchData searchData = new Components.JSSearchData(this); searchData.BuildPrefixDataFile(prefix, accessor, cancelDelegate); }
// Group: Functions // __________________________________________________________________________ /* Function: BuildSearchPrefixIndex */ protected void BuildSearchPrefixIndex(CodeDB.Accessor accessor, CancelDelegate cancelDelegate) { Components.JSSearchData searchData = new Components.JSSearchData(this); searchData.BuildPrefixIndex(); }
/* Function: BuildDataFiles * * Builds the content HTML file for the passed <Context's> <PageLocation> and its supporting <JSONSummary> and * <JSONToolTips>. Returns whether there was any content. It will also return false if it was interrupted by the * <CancelDelegate>. * * If the <CodeDB.Accessor> doesn't have a lock, this function will automatically acquire and release a read-only * lock. This is the preferred way of using this function as the lock will only be held during the data querying stage * and will be released before writing output to disk. * * If it already had a lock it will use it and not release it unless you set releaseExistingLocks. */ public bool BuildDataFiles(Context context, CodeDB.Accessor accessor, CancelDelegate cancelDelegate, bool releaseExistingLocks = false) { this.Context = context; var location = context.Page; List <Engine.Topics.Topic> topics; List <Engine.Links.Link> links; List <Engine.Links.ImageLink> imageLinks; bool releaseDBLock = false; if (accessor.LockHeld == CodeDB.Accessor.LockType.None) { accessor.GetReadOnlyLock(); releaseDBLock = true; } else if (releaseExistingLocks) { releaseDBLock = true; } try { // Get the topics from the database. if (location.IsSourceFile) { topics = accessor.GetTopicsInFile(location.FileID, cancelDelegate); } else if (location.InHierarchy) { topics = accessor.GetTopicsInClass(location.ClassID, cancelDelegate); } else { throw new NotImplementedException(); } if (topics == null || topics.Count == 0 || cancelDelegate()) { return(false); } // Create the class view if appropriate if (location.InHierarchy) { ClassView.Merge(ref topics, EngineInstance); // It's possible for ClassView to reduce the number of topics to zero, so check for that and treat it as if the // class page has no content. if (topics.Count == 0) { return(false); } } // Get the links from the database. if (location.IsSourceFile) { links = accessor.GetLinksInFile(location.FileID, cancelDelegate) ?? new List <Engine.Links.Link>(); imageLinks = accessor.GetImageLinksInFile(location.FileID, cancelDelegate) ?? new List <Engine.Links.ImageLink>(); } else if (location.InHierarchy) { links = accessor.GetLinksInClass(location.ClassID, cancelDelegate) ?? new List <Engine.Links.Link>(); imageLinks = accessor.GetImageLinksInClass(location.ClassID, cancelDelegate) ?? new List <Engine.Links.ImageLink>(); } else { throw new NotImplementedException(); } if (cancelDelegate()) { return(false); } // Find all the classes that are defined in this page, since we have to do additional lookups for class prototypes. IDObjects.NumberSet classIDsDefined = new IDObjects.NumberSet(); foreach (var topic in topics) { if (topic.DefinesClass) { classIDsDefined.Add(topic.ClassID); } } if (cancelDelegate()) { return(false); } // We need the class parent links of all the classes defined on this page so the class prototypes can show the parents. // If this is a class page then we can skip this step since all the links should already be included. However, for any // other type of page this may not be the case. A source file page would return all the links in that file, but the class // may be defined across multiple files and we need the class parent links in all of them. In this case we need to look // up the class parent links separately by class ID. if (location.InHierarchy == false && classIDsDefined.IsEmpty == false) { List <Engine.Links.Link> classParentLinks = accessor.GetClassParentLinksInClasses(classIDsDefined, cancelDelegate); if (classParentLinks != null && classParentLinks.Count > 0) { links.AddRange(classParentLinks); } } if (cancelDelegate()) { return(false); } // Now we need to find the children of all the classes defined on this page. Get the class parent links that resolve to // any of the defined classes, but keep them separate for now. List <Engine.Links.Link> childLinks = null; if (classIDsDefined.IsEmpty == false) { childLinks = accessor.GetClassParentLinksToClasses(classIDsDefined, cancelDelegate); } if (cancelDelegate()) { return(false); } // Get link targets for everything but the children, since they would just resolve to classes already in this file. IDObjects.NumberSet linkTargetIDs = new IDObjects.NumberSet(); foreach (var link in links) { if (link.IsResolved) { linkTargetIDs.Add(link.TargetTopicID); } } List <Engine.Topics.Topic> linkTargets = accessor.GetTopicsByID(linkTargetIDs, cancelDelegate) ?? new List <Engine.Topics.Topic>(); if (cancelDelegate()) { return(false); } // Now get targets for the children. List <Engine.Topics.Topic> childTargets = null; if (childLinks != null && childLinks.Count > 0) { IDObjects.NumberSet childClassIDs = new IDObjects.NumberSet(); foreach (var childLink in childLinks) { childClassIDs.Add(childLink.ClassID); } childTargets = accessor.GetBestClassDefinitionTopics(childClassIDs, cancelDelegate); } if (cancelDelegate()) { return(false); } // We can merge the child links and targets into the main lists now. if (childLinks != null) { links.AddRange(childLinks); } if (childTargets != null) { linkTargets.AddRange(childTargets); } if (cancelDelegate()) { return(false); } // Now we need to find any Natural Docs and image links appearing inside the summaries of link targets. // The tooltips that will be generated for them include their summaries, and even though we don't generate // HTML links inside tooltips, how and if they're resolved affects their appearance. We need to know whether // to include the original text with angle brackets, the text without angle brackets if it's resolved, or only part // of the text if it's a resolved named link. // Links don't store which topic they appear in but they do store the file, so gather the file IDs of the link // targets that have Natural Docs or image links in the summaries and get all the links in those files. // Links also store which class they appear in, so why not do this by class instead of by file? Because a // link could be to something global, and the global scope could potentially have a whole hell of a lot of // content, depending on the project and language. While there can also be some really long files, the // chances of that are smaller so we stick with doing this by file. IDObjects.NumberSet summaryLinkFileIDs = new IDObjects.NumberSet(); foreach (var linkTarget in linkTargets) { if (linkTarget.Summary != null && (linkTarget.Summary.IndexOf("<link type=\"naturaldocs\"") != -1 || linkTarget.Summary.IndexOf("<image ") != -1)) { summaryLinkFileIDs.Add(linkTarget.FileID); } } List <Engine.Links.Link> summaryLinks = null; List <Engine.Links.ImageLink> summaryImageLinks = null; if (!summaryLinkFileIDs.IsEmpty) { summaryLinks = accessor.GetNaturalDocsLinksInFiles(summaryLinkFileIDs, cancelDelegate); summaryImageLinks = accessor.GetImageLinksInFiles(summaryLinkFileIDs, cancelDelegate); } if (cancelDelegate()) { return(false); } // Finally done with the database. if (releaseDBLock) { accessor.ReleaseLock(); releaseDBLock = false; } // Determine the page title string pageTitle; if (context.Page.IsSourceFile) { pageTitle = EngineInstance.Files.FromID(context.Page.FileID).FileName.NameWithoutPath; } else if (context.Page.InHierarchy) { pageTitle = context.Page.ClassString.Symbol.LastSegment; } else { throw new NotImplementedException(); } // Build the HTML for the list of topics StringBuilder html = new StringBuilder("\r\n\r\n"); HTML.Components.Topic topicBuilder = new HTML.Components.Topic(context); HTML.Components.Tooltip tooltipBuilder = new HTML.Components.Tooltip(context); // We don't put embedded topics in the output, so we need to find the last non-embedded one to make // sure that the "last" CSS tag is correctly applied. int lastNonEmbeddedTopic = topics.Count - 1; while (lastNonEmbeddedTopic > 0 && topics[lastNonEmbeddedTopic].IsEmbedded == true) { lastNonEmbeddedTopic--; } for (int i = 0; i <= lastNonEmbeddedTopic; i++) { string extraClass = null; if (i == 0) { extraClass = "first"; } else if (i == lastNonEmbeddedTopic) { extraClass = "last"; } if (topics[i].IsEmbedded == false) { topicBuilder.AppendTopic(topics[i], context, links, linkTargets, imageLinks, html, topics, i + 1, extraClass); html.Append("\r\n\r\n"); } } // Build the full HTML file Build(context.OutputFile, pageTitle, html.ToString(), PageType.Content); // Build summary and tooltips files JSONSummary summaryBuilder = new JSONSummary(context); summaryBuilder.ConvertToJSON(topics, context); summaryBuilder.BuildDataFile(pageTitle); JSONToolTips toolTipsBuilder = new JSONToolTips(context); toolTipsBuilder.ConvertToJSON(topics, links, imageLinks, context); toolTipsBuilder.BuildDataFileForSummary(); toolTipsBuilder.ConvertToJSON(linkTargets, summaryLinks, summaryImageLinks, context); toolTipsBuilder.BuildDataFileForContent(); return(true); } catch (Exception e) { try { e.AddNaturalDocsTask("Building HTML file " + context.OutputFile); } catch { } throw; } finally { if (releaseDBLock) { accessor.ReleaseLock(); } } }
/* Function: GetPrefixKeywords */ protected List <SearchIndex.KeywordEntry> GetPrefixKeywords(string prefix, CodeDB.Accessor accessor, CancelDelegate cancelDelegate) { return(EngineInstance.SearchIndex.GetKeywordEntries(prefix, accessor, cancelDelegate)); }
/* Function: GetKeywordEntries * Returns a list of all the <KeywordEntries> for a prefix, complete with all their <TopicEntries>. If there are none it will return * null. The returned list will not be in any particular order, it is up to the calling code to sort them as desired. */ public List <KeywordEntry> GetKeywordEntries(string prefix, CodeDB.Accessor accessor, CancelDelegate cancelDelegate) { // Retrieve the topics from the database IDObjects.NumberSet topicIDs = PrefixTopicIDs(prefix); if (topicIDs == null || topicIDs.IsEmpty) { return(null); } List <Topic> topics = null; bool releaseDBLock = false; if (accessor.LockHeld == CodeDB.Accessor.LockType.None) { accessor.GetReadOnlyLock(); releaseDBLock = true; } try { // Need to lookup class strings to be able to build hash paths topics = accessor.GetTopicsByID(topicIDs, cancelDelegate, CodeDB.Accessor.GetTopicFlags.BodyLengthOnly | CodeDB.Accessor.GetTopicFlags.DontLookupContexts | CodeDB.Accessor.GetTopicFlags.DontIncludeSummary | CodeDB.Accessor.GetTopicFlags.DontIncludePrototype); } finally { if (releaseDBLock) { accessor.ReleaseLock(); } } if (cancelDelegate()) { return(null); } // Convert the topics into entries StringTable <KeywordEntry> keywordEntryTable = new StringTable <KeywordEntry>(KeySettings.IgnoreCase); foreach (var topic in topics) { TopicEntry topicEntry = new TopicEntry(topic, this); foreach (var keyword in topicEntry.Keywords) { if (KeywordMatchesPrefix(keyword, prefix)) { var keywordEntry = keywordEntryTable[keyword]; if (keywordEntry == null) { keywordEntry = new KeywordEntry(keyword); keywordEntryTable[keyword] = keywordEntry; } else if (keywordEntry.Keyword != keyword) { // If they differ in case we still want to combine them, but we have to choose which case will be in the // results. Prioritize in this order: mixed case ('m'), all lowercase ('l'), all uppercase ('u'). char keywordCase, keywordEntryCase; if (keyword == keyword.ToLower()) { keywordCase = 'l'; } else if (keyword == keyword.ToUpper()) { keywordCase = 'u'; } else { keywordCase = 'm'; } if (keywordEntry.Keyword == keywordEntry.Keyword.ToLower()) { keywordEntryCase = 'l'; } else if (keywordEntry.Keyword == keywordEntry.Keyword.ToUpper()) { keywordEntryCase = 'u'; } else { keywordEntryCase = 'm'; } if ((keywordCase == 'm' && keywordEntryCase != 'm') || (keywordCase == 'l' && keywordEntryCase == 'u')) { keywordEntry.Keyword = keyword; } else if (keywordCase == 'm' && keywordEntryCase == 'm') { // If they're both mixed, use the sort order. This lets SomeValue be used instead of someValue. if (string.Compare(keyword, keywordEntry.Keyword) > 0) { keywordEntry.Keyword = keyword; } } } keywordEntry.TopicEntries.Add(topicEntry); } } } List <KeywordEntry> keywordEntries = new List <KeywordEntry>(keywordEntryTable.Count); foreach (var keywordEntryTablePair in keywordEntryTable) { keywordEntries.Add(keywordEntryTablePair.Value); } return(keywordEntries); }
/* Function: BuildPrefixDataFile * * Creates a data file for a single prefix as described in <JavaScript Search Data>. It requires a <CodeDB.Accessor> to * be able to get information about each search result, such as what type it is and the hash path needed to get t oit. * * Pass a <CancelDelegate> if you need to be able to interrupt the process, or <Delegates.NeverCancel> if not. */ public void BuildPrefixDataFile(string prefix, CodeDB.Accessor accessor, CancelDelegate cancelDelegate) { Path path = Paths.SearchIndex.PrefixOutputFile(context.Target.OutputFolder, prefix); // Get and sort the keywords and topics var keywordEntries = GetPrefixKeywords(prefix, accessor, cancelDelegate); if (keywordEntries == null || keywordEntries.Count == 0) { if (System.IO.File.Exists(path)) { System.IO.File.Delete(path); } return; } SortKeywordEntries(keywordEntries); foreach (var keywordEntry in keywordEntries) { SortTopicEntries(keywordEntry); RemoveDuplicateTopics(keywordEntry); } // Build the list of all used comment types if (usedCommentTypes == null) { usedCommentTypes = new List <CommentType>(); } else { usedCommentTypes.Clear(); } foreach (var keywordEntry in keywordEntries) { foreach (var topicEntry in keywordEntry.TopicEntries) { int commentTypeID = topicEntry.WrappedTopic.CommentTypeID; if (UsedCommentTypeIndex(commentTypeID) == -1) { usedCommentTypes.Add(EngineInstance.CommentTypes.FromID(commentTypeID)); } } } // Build the output StringBuilder output = new StringBuilder(); bool addWhitespace = !EngineInstance.Config.ShrinkFiles; output.Append("NDSearch.OnPrefixDataLoaded(\""); output.StringEscapeAndAppend(prefix); output.Append("\","); if (addWhitespace) { output.Append("\n "); } AppendUsedCommentTypes(output); output.Append(','); if (addWhitespace) { output.Append("\n "); } output.Append('['); bool isFirstKeywordEntry = true; foreach (var keywordEntry in keywordEntries) { if (isFirstKeywordEntry) { isFirstKeywordEntry = false; } else { output.Append(','); } AppendKeyword(keywordEntry, output); } if (addWhitespace) { output.Append("\n\n"); } output.Append("]);"); // Write it to the file WriteTextFile(path, output.ToString()); }