/// <summary> /// Gets the vote partitions of a plan. /// </summary> /// <param name="lines">The lines of a vote plan.</param> /// <param name="partitionMode">The partition mode being used.</param> /// <param name="author">The author of the post.</param> /// <returns>Returns the vote partitioned appropriately.</returns> private List <string> GetVotePartitionsFromPlan(IEnumerable <string> lines, PartitionMode partitionMode, string author) { switch (partitionMode) { case PartitionMode.None: // No partitioning; no special treatment return(PartitionByNone(lines, author)); case PartitionMode.ByLine: // When partitioning by line, promote the plan first. // The label line can be discarded, and the others treated as less indented. return(PartitionByLine(PromoteLines(lines), author)); case PartitionMode.ByLineTask: // When partitioning by line, promote the plan first. // The label line can be discarded, and the others treated as less indented. return(PartitionByLineTask(lines, author)); case PartitionMode.ByBlock: // Normal block partitioning means we don't partition plans. // They will end up as a single block for the regular vote to consume. return(PartitionByNone(lines, author)); case PartitionMode.ByBlockAll: // When partitioning by BlockAll, any plans are themselves partitioned by block (after promotion). // Make sure to preserve the task from the main line on the resulting blocks. string planTask = VoteString.GetVoteTask(lines.First()); var blocks = PartitionByBlock(PromoteLines(lines), author); return(ApplyTaskToBlocks(blocks, planTask)); default: throw new ArgumentException($"Unknown partition mode: {partitionMode}"); } }
/// <summary> /// Takes a list of string lines and, if the first line contains a plan /// name using "Base Plan", convert it to a version that only uses "Plan". /// </summary> /// <param name="lines">A list of lines defining a plan.</param> /// <returns>Returns the list of lines, with the assurance that /// any plan name starts with just "Plan".</returns> private static IEnumerable <string> NormalizePlanName(IEnumerable <string> lines) { string firstLine = lines.First(); var remainder = lines.Skip(1); string nameContent = VoteString.GetVoteContent(firstLine, VoteType.Plan); Match m = basePlanRegex.Match(nameContent); if (m.Success) { nameContent = $"Plan{m.Groups[1]}{m.Groups["planname"]}"; firstLine = VoteString.ModifyVoteLine(firstLine, content: nameContent); List <string> results = new List <string>(lines.Count()) { firstLine }; results.AddRange(remainder); return(results); } return(lines); }
/// <summary> /// Add the specified task to all the provided blocks, if they don't /// already have a task. /// </summary> /// <param name="blocks">A list of vote blocks.</param> /// <param name="planTask">A task name to apply. If no name is provided, no changes are made.</param> /// <returns>Returns the vote blocks with the task applied.</returns> private static List <string> ApplyTaskToBlocks(List <string> blocks, string planTask) { if (blocks == null) { throw new ArgumentNullException(nameof(blocks)); } if (string.IsNullOrEmpty(planTask)) { return(blocks); } List <string> results = new List <string>(); foreach (var block in blocks) { if (VoteString.GetVoteTask(block).Length == 0) { string rep = VoteString.ModifyVoteLine(block, task: planTask, byPartition: true); results.Add(rep); } else { results.Add(block); } } return(results); }
/// <summary> /// Filters the plans by task. /// </summary> /// <param name="plans">The plans.</param> /// <param name="taskFilter">The task filter.</param> /// <returns>Returns the plans after filtering with the task filter.</returns> private static List <List <string> > FilterPlansByTask(List <List <string> > plans, IQuest quest) { if (!quest.UseCustomTaskFilters) { return(plans); } // Include lines where the task filter matches var filtered = plans.Where(p => quest.TaskFilter.Match(VoteString.GetVoteTask(p.First()))); return(filtered.ToList()); }
/// <summary> /// Partition a vote by line, but carry any task on parent lines down /// to child lines. /// </summary> /// <param name="lines">The lines of the vote.</param> /// <param name="author">The author of the vote, for use in determining /// valid referrals.</param> /// <returns>Returns a list of partitioned vote lines.</returns> private List <string> PartitionByLineTask(IEnumerable <string> lines, string author) { List <string> partitions = new List <string>(); List <string> referralVotes = new List <string>(); string parentTask = string.Empty; foreach (string line in lines) { // If someone copy/pasted a vote with a referral at the top (eg: self-named plan), // skip the copy/pasted section. if (referralVotes.Any()) { if (Agnostic.StringComparer.Equals(line, referralVotes.First())) { referralVotes = referralVotes.Skip(1).ToList(); continue; } referralVotes.Clear(); } referralVotes = VoteCounter.GetVotesFromReference(line, author); if (referralVotes.Any()) { partitions.AddRange(referralVotes); if (Agnostic.StringComparer.Equals(line, referralVotes.First())) { referralVotes = referralVotes.Skip(1).ToList(); continue; } } else { string taskedLine = line; if (string.IsNullOrEmpty(VoteString.GetVotePrefix(line))) { parentTask = VoteString.GetVoteTask(line); } else if (string.IsNullOrEmpty(VoteString.GetVoteTask(line))) { taskedLine = VoteString.ModifyVoteLine(line, task: parentTask); } partitions.Add(taskedLine + "\r\n"); } } return(partitions); }
/// <summary> /// Get the name of a voter that is referenced if that is the only /// reference in the vote. /// </summary> /// <param name="post">The post.</param> /// <returns></returns> private string GetPureRankReference(PostComponents post) { if (post.VoteLines.Count == 1) { var refNames = VoteString.GetVoteReferenceNames(post.VoteLines.First()); var refVoter = refNames[ReferenceType.Voter].FirstOrDefault(n => n != post.Author && VoteCounter.HasUserEnteredVoter(n, VoteType.Rank)); return(refVoter); } return(null); }
/// <summary> /// Store original plan name and contents in reference containers. /// </summary> /// <param name="plans">A list of valid plans.</param> private void StorePlanReferences(IEnumerable <List <string> > plans) { foreach (var plan in plans) { string planName = VoteString.GetPlanName(plan.First()); string cleanName = VoteString.RemoveBBCode(planName); cleanName = VoteString.DeUrlContent(cleanName); if (!VoteCounter.ReferencePlanNames.Contains(cleanName, Agnostic.StringComparer)) { VoteCounter.ReferencePlanNames.Add(cleanName); VoteCounter.ReferencePlans[cleanName] = plan; } } }
/// <summary> /// If all sub-lines of a provided group of lines are indented (have a prefix), /// then 'promote' them up a tier (remove one level of the prefix) while discarding /// the initial line. /// </summary> /// <param name="lines">A list of strings to examine/promote.</param> /// <returns>Returns the strings without the initial line, and with the /// remaining lines reduced by one indent level.</returns> private static IEnumerable <string> PromoteLines(IEnumerable <string> lines) { if (lines == null) { throw new ArgumentNullException(nameof(lines)); } var remainder = lines.Skip(1); if (remainder.All(l => VoteString.GetVotePrefix(l).Length > 0)) { return(remainder.Select(l => l.Substring(1).Trim())); } return(remainder); }
/// <summary> /// Gets a list of all full-vote plans (of which there will only be one, if found). /// </summary> /// <param name="post">The post to extract plans from.</param> /// <returns>Returns a list of plans (which are lists of vote lines).</returns> private List <List <string> > GetAllFullPostPlans(PostComponents post) { List <List <string> > results = new List <List <string> >(); if (post.VoteLines.Any()) { // Group blocks based on parent vote lines (no prefix). // Key for each block is the parent vote line. var voteBlocks = post.VoteLines.GroupAdjacentToPreviousKey( (s) => string.IsNullOrEmpty(VoteString.GetVotePrefix(s)), (s) => s, (s) => s); // If the vote has any plans with content in them, we can't make this a full-post plan. if (!voteBlocks.Any(b => b.Count() > 1 && VoteString.GetPlanName(b.Key) != null)) { // The post must have more than one line to count for a plan label. if (post.VoteLines.Count > 1) { var firstLine = post.VoteLines.First(); string planname = VoteString.GetPlanName(firstLine); if (planname != null) { // If it's named after a user, it must be the post author. Otherwise, anything is fine. if (VoteCounter.ReferenceVoters.Contains(planname, Agnostic.StringComparer)) { if (Agnostic.StringComparer.Equals(planname, post.Author)) { results.Add(post.VoteLines); } } else { results.Add(post.VoteLines); } } } } } return(results); }
/// <summary> /// Gets a list of all plans within the post that have defined content (child lines). /// </summary> /// <param name="post">The post to extract plans from.</param> /// <returns>Returns a list of plans (which are lists of vote lines).</returns> private List <List <string> > GetAllPlansWithContent(PostComponents post) { List <List <string> > results = new List <List <string> >(); results.AddRange(post.BasePlans.Select(a => a.ToList())); if (post.VoteLines.Any()) { // Group blocks based on parent vote lines (no prefix). // Key for each block is the parent vote line. var voteBlocks = post.VoteLines.GroupAdjacentToPreviousKey( (s) => string.IsNullOrEmpty(VoteString.GetVotePrefix(s)), (s) => s, (s) => s); foreach (var block in voteBlocks) { if (block.Count() > 1) { string planname = VoteString.GetPlanName(block.Key); if (planname != null) { // Add a named vote that is named after a user only if it matches the post author's name. if (VoteCounter.ReferenceVoters.Contains(planname, Agnostic.StringComparer)) { if (Agnostic.StringComparer.Equals(planname, post.Author)) { results.Add(block.ToList()); } } else { // If it's not named after a user, add it normally. results.Add(block.ToList()); } } } } } return(results); }
/// <summary> /// Put any plans found in the grouped vote lines into the standard tracking sets, /// after handling any partitioning needed. /// </summary> /// <param name="plans">List of plans to be processed.</param> /// <param name="post">Post the plans were pulled from.</param> /// <param name="partitionMode">Partition mode being used.</param> private void ProcessPlans(IEnumerable <List <string> > plans, PostComponents post, PartitionMode partitionMode) { foreach (var plan in plans) { string planName = VoteString.GetMarkedPlanName(plan.First()); string cleanName = VoteString.RemoveBBCode(planName); cleanName = VoteString.DeUrlContent(cleanName); if (!VoteCounter.HasPlan(cleanName)) { var nPlan = NormalizePlanName(plan); // Get the list of all vote partitions, built according to current preferences. // One of: By line, By block, or By post (ie: entire vote) var votePartitions = GetVotePartitions(nPlan, partitionMode, VoteType.Plan, post.Author); VoteCounter.AddVotes(votePartitions, cleanName, post.ID, VoteType.Plan); } } }
/// <summary> /// Gets a list of all full-vote plans (of which there will only be one, if found). /// </summary> /// <param name="post">The post to extract plans from.</param> /// <returns>Returns a list of plans (which are lists of vote lines).</returns> private List <List <string> > GetAllOneLinePlans(PostComponents post) { List <List <string> > results = new List <List <string> >(); if (post.VoteLines.Any()) { // Group blocks based on parent vote lines (no prefix). // Key for each block is the parent vote line. var voteBlocks = post.VoteLines.GroupAdjacentToPreviousKey( (s) => string.IsNullOrEmpty(VoteString.GetVotePrefix(s)), (s) => s, (s) => s); foreach (var block in voteBlocks) { if (block.Count() == 1) { string planname = VoteString.GetPlanName(block.Key); if (planname != null) { if (VoteCounter.ReferenceVoters.Contains(planname, Agnostic.StringComparer)) { if (Agnostic.StringComparer.Equals(planname, post.Author)) { results.Add(block.ToList()); } } else { results.Add(block.ToList()); } } } } } return(results); }
/// <summary> /// Filters the votes by task. /// </summary> /// <param name="lines">The lines.</param> /// <param name="taskFilter">The task filter.</param> /// <returns>Returns the votes after filtering with the task filter.</returns> private static List <string> FilterVotesByTask(List <string> lines, IQuest quest) { if (!quest.UseCustomTaskFilters) { return(lines); } List <string> results = new List <string>(); foreach (var line in lines) { string firstLine = line.GetFirstLine(); string task = VoteString.GetVoteTask(firstLine); bool check = quest.TaskFilter.Match(task); if (check) { results.Add(line); } } return(results); }
public static VoteLine?ParseLine(ReadOnlySpan <char> line) { if (line.Length == 0) { return(null); } StringBuilder prefixSB = new StringBuilder(); StringBuilder markerSB = new StringBuilder(); StringBuilder taskSB = new StringBuilder(); StringBuilder contentSB = new StringBuilder(); StringBuilder tempContent = new StringBuilder(); MarkerType markerType = MarkerType.None; int markerValue = 0; Stack <TokenState> state = new Stack <TokenState>(); TokenState currentState = TokenState.None; for (int c = 0; c < line.Length; c++) { char ch = line[c]; // Skip newlines entirely, if they somehow get into the line we're parsing. if (newlineChars.Contains(ch)) { continue; } switch (currentState) { case TokenState.None: if (ch == whitespace) { continue; } else if (prefixChars.Contains(ch)) { prefixSB.Append(ch); currentState = TokenState.Prefix; } else if (ch == openBracket) { currentState = TokenState.Marker; } else if (ch == xBox || ch == checkBox) { // Shortcut for a complete marker markerSB.Append(ch); (markerType, markerValue) = GetMarkerType(markerSB.ToString()); currentState = TokenState.PostMarker; } else if (ch == openBBCode) { state.Push(currentState); currentState = TokenState.BBCode; } else { goto doneExamining; } break; case TokenState.Prefix: if (ch == whitespace) { continue; } else if (prefixChars.Contains(ch)) { prefixSB.Append(ch); } else if (ch == openBracket) { currentState = TokenState.Marker; } else if (ch == xBox || ch == checkBox) { // Shortcut for a complete marker markerSB.Append(ch); (markerType, markerValue) = GetMarkerType(markerSB.ToString()); currentState = TokenState.PostMarker; } else if (ch == openBBCode) { state.Push(currentState); currentState = TokenState.BBCode; } else { goto doneExamining; } break; case TokenState.Marker: if (ch == whitespace) { continue; } else if (markerChars.Contains(ch)) { markerSB.Append(ch); } else if (ch == closeBracket) { (markerType, markerValue) = GetMarkerType(markerSB.ToString()); if (markerType != MarkerType.None) { currentState = TokenState.PostMarker; } else { goto doneExamining; } } else if (ch == openBBCode) { state.Push(currentState); currentState = TokenState.BBCode; } else { goto doneExamining; } break; case TokenState.PostMarker: if (ch == whitespace) { if (tempContent.Length > 0) { tempContent.Append(ch); } continue; } else if (ch == openBracket && taskSB.Length == 0) { state.Push(currentState); currentState = TokenState.Task; } else if (ch == openBBCode && taskSB.Length == 0) { state.Push(currentState); currentState = TokenState.BBCode; tempContent.Append(ch); } else if (ch == openStrike) { tempContent.Append("『s』"); state.Push(currentState); currentState = TokenState.Strike; } else { contentSB.Append(tempContent); tempContent.Clear(); contentSB.Append(ch); currentState = TokenState.Content; } break; case TokenState.Task: tempContent.Clear(); if (ch == closeBracket) { currentState = state.Pop(); } else if (ch == openBBCode) { state.Push(currentState); currentState = TokenState.BBCode; } else if (ch == openStrike) { state.Push(currentState); currentState = TokenState.Strike; } else { taskSB.Append(ch); } break; case TokenState.Content: if (tempContent.Length > 0) { contentSB.Append(tempContent); tempContent.Clear(); } if (ch == openStrike) { tempContent.Append("『s』"); state.Push(currentState); currentState = TokenState.Strike; } else if (apostraphes.Contains(ch)) { contentSB.Append('\''); } else if (quotations.Contains(ch)) { contentSB.Append('"'); } else { contentSB.Append(ch); } break; case TokenState.BBCode: if (state.Peek() == TokenState.PostMarker) { tempContent.Append(ch); } if (ch == closeBBCode) { currentState = state.Pop(); } break; case TokenState.Strike: // Strike-through text is only preserved in the content area if (ch == closeStrike) { tempContent.Append("『/s』"); currentState = state.Pop(); } else if (ch == strikeNewline) { // If we hit embedded newlines, bail out entirely. // Take whatever's been done up to that point. tempContent.Clear(); currentState = state.Pop(); goto doneExamining; } else { tempContent.Append(ch); } break; default: throw new InvalidOperationException($"Unknown token state value: {currentState}."); } } doneExamining: if (currentState == TokenState.Content) { string content = VoteString.NormalizeContentBBCode(contentSB.ToString()); return(new VoteLine(prefixSB.ToString(), markerSB.ToString(), taskSB.ToString(), content, markerType, markerValue)); } return(null); }
/// <summary> /// Get the lines of the vote that we will be processing out of the post. /// Only take the .VoteLines, and condense any instances of known plans /// to just a reference to the plan name. /// </summary> /// <param name="post">The post we're getting the vote from.</param> /// <returns>Returns the vote with plans compressed.</returns> public List <string> GetWorkingVote(PostComponents post) { List <string> vote = new List <string>(); if (post == null || !post.IsVote) { return(vote); } // First determine if any base plans are copies of an original definition, or being defined in this post. // If they're just copies, then embed them in the working vote. if (post.BasePlans.Any()) { var voters = VoteCounter.GetVotersCollection(VoteType.Plan); bool checkPlan = true; string planName; foreach (var bPlan in post.BasePlans) { planName = VoteString.GetMarkedPlanName(bPlan.Key); if (planName == null) { continue; } // As long as we keep finding base plans that are defined in this post, keep skipping. if (checkPlan) { if (VoteCounter.HasPlan(planName) && voters[planName] == post.ID) { continue; } } checkPlan = false; // If we reach here, any further plans are copy/pastes of defined plans, and should // have the key added to the working vote. vote.Add(bPlan.Key); } } // Then make sure there are actual vote lines to process. if (!post.VoteLines.Any()) { return(vote); } // Then check if the *entire post* should be treated as a complete plan. string postPlanName = VoteString.GetPlanName(post.VoteLines.First()); if (postPlanName != null && VoteCounter.ReferencePlans.ContainsKey(postPlanName) && VoteCounter.ReferencePlans[postPlanName].Skip(1).SequenceEqual(post.VoteLines.Skip(1), Agnostic.StringComparer)) { // Replace known plans with just the plan key. They'll be expanded later. vote.Add(post.VoteLines.First()); } else { // If the entire post isn't an auto-plan, break it down into blocks. // Break the remainder of the vote into blocks so that we can compare vs auto-plans. // Group blocks based on parent vote lines (no prefix). // Key for each block is the parent vote line. var voteBlocks = post.VoteLines.GroupAdjacentToPreviousKey( (s) => string.IsNullOrEmpty(VoteString.GetVotePrefix(s)), (s) => s, (s) => s); foreach (var block in voteBlocks) { // Multi-line blocks might be a plan. Check. if (block.Count() > 1) { // See if the block key marks a known plan. string planName = VoteString.GetPlanName(block.Key); if (planName != null && VoteCounter.ReferencePlans.ContainsKey(planName) && VoteCounter.ReferencePlans[planName].Skip(1).SequenceEqual(block.Skip(1), Agnostic.StringComparer)) { // Replace known plans with just the plan key. They'll be expanded later. vote.Add(block.Key); } else { // If it's not a known plan, pass everything through. vote.AddRange(block); } } else { // Single lines can be added normally vote.AddRange(block); //vote.Add(block.Key); } } } return(vote); }
/// <summary> /// Partition the provided vote into individual partitions, by block. /// Referral votes are added as their own partitioned form. /// </summary> /// <param name="lines">The lines of a vote.</param> /// <param name="author">The author of the post.</param> /// <returns>Returns a the vote partitioned by block.</returns> private List <string> PartitionByBlock(IEnumerable <string> lines, string author) { List <string> partitions = new List <string>(); List <string> referralVotes = new List <string>(); StringBuilder sb = new StringBuilder(); foreach (string line in lines) { // If someone copy/pasted a vote with a referral at the top (eg: self-named plan), // skip the copy/pasted section. if (referralVotes.Any()) { if (Agnostic.StringComparer.Equals(line, referralVotes.First())) { referralVotes = referralVotes.Skip(1).ToList(); continue; } referralVotes.Clear(); } referralVotes = VoteCounter.GetVotesFromReference(line, author); if (referralVotes.Any()) { if (sb.Length > 0) { partitions.Add(sb.ToString()); sb.Clear(); } partitions.AddRange(referralVotes); referralVotes = referralVotes.SelectMany(a => a.GetStringLines()).ToList(); if (Agnostic.StringComparer.Equals(line, referralVotes.First())) { referralVotes = referralVotes.Skip(1).ToList(); continue; } } else { string prefix = VoteString.GetVotePrefix(line); // If we encountered a new top-level vote line, store any existing stringbuilder contents. if (string.IsNullOrEmpty(prefix) && sb.Length > 0) { partitions.Add(sb.ToString()); sb.Clear(); } sb.AppendLine(line); } } if (sb.Length > 0) { partitions.Add(sb.ToString()); } return(partitions); }
/// <summary> /// Determine if there are any references to future (unprocessed) user votes /// within the current vote. /// </summary> /// <param name="post">Post containing the current vote.</param> /// <returns>Returns true if a future reference is found. Otherwise false.</returns> private bool HasFutureReference(PostComponents post, IQuest quest) { // If we decide it has to be forced, ignore all checks in here. if (post.ForceProcess) { return(false); } // If proxy votes are disabled, we don't need to look for proxy names, so there can't be future references. // Likewise, if we're forcing all proxy votes to be pinned, there can't be any future references. if (quest.DisableProxyVotes || quest.ForcePinnedProxyVotes) { return(false); } foreach (var line in post.WorkingVote) { // Get the possible proxy references this line contains var refNames = VoteString.GetVoteReferenceNames(line); // Pinned references (^ or pin keywords) are explicitly not future references if (refNames[ReferenceType.Label].Any(a => a == "^" || a == "pin")) { continue; } // Any references to plans automatically work, as they are defined in a preprocess phase. if (refNames[ReferenceType.Plan].Any(VoteCounter.HasPlan)) { continue; } string refVoter = refNames[ReferenceType.Voter].FirstOrDefault(n => VoteCounter.ReferenceVoters.Contains(n, Agnostic.StringComparer)) ?.AgnosticMatch(VoteCounter.ReferenceVoters); if (refVoter != null && refVoter != post.Author) { var refVoterPosts = VoteCounter.PostsList.Where(p => p.Author == refVoter).ToList(); // If ref voter has no posts (how did we get here?), it can't be a future reference. if (!refVoterPosts.Any()) { continue; } // If the referenced voter never made a real vote (eg: only made base plans or rank votes), // then this can't be treated as a future reference. var refWorkingVotes = refVoterPosts.Where(p => p.WorkingVote.Count > 0); if (!refWorkingVotes.Any()) { continue; } // If there's no 'plan' label, then we need to verify that the last vote that the // ref voter made (has a working vote) was processed. // If it's been processed, then we're OK to let this vote through. if (refWorkingVotes.Last().Processed) { continue; } // If none of the conditions above are met, then consider this a future reference. return(true); } } // No future references were found. return(false); }