src/models/node.ts

Summary

Maintainability
B
4 hrs
Test Coverage
B
85%
import { TreeItem } from '@/components/Tree/types'
import { Task } from '@/models/task'
import { Group } from '@/models/group'
import Log from '@/services/log'
import { rand, depthToIndent, hasProperties } from '@/services/util'
import { flat } from './flattenedNode'
import { IClonable } from '@/@types/global'
import { TASK_DEFAULT } from '@/const'

/**
 * Represent types of the Node.
 */
export const NODE_TYPE = {
  TASK: 'TASK',
  HEADING: 'HEADING',
  OTHER: 'OTHER',
  ROOT: 'ROOT',
}
type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE]

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function isClonable<T>(arg: any): arg is IClonable<T> {
  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
  return arg && arg.clone !== undefined
}

export interface INode {
  type: NodeType
  line: number
  data: Task | Group | string
  parent: Node
  children: Node[]
  id: string
  collapsed: boolean

  toString(): string
  clone(): INode
}

type Predicate = (n: Node) => boolean
type Callback = (n: Node) => void

export class Node implements TreeItem, INode, IClonable<INode> {
  public type: NodeType
  public line: number
  public data: Task | Group | string
  public parent: Node
  public children: Node[]
  public collapsed: boolean
  public id: string

  public static tryParse(obj: unknown): Node | null {
    if (
      hasProperties(obj, 'type', 'line', 'data', 'parent', 'children', 'id')
    ) {
      const type = obj.type as NodeType
      const line = obj.line as number
      const data = obj.data as string
      const parent = obj.parent as Node

      const node = new Node(type, line, data, parent)
      const children = obj.children as Array<Node>
      node.children.push(...children)
      node.id = obj.id as string
      return node
    } else {
      return null
    }
  }

  public constructor(
    type: NodeType,
    line: number,
    data: Task | Group | string,
    parent?: Node,
  ) {
    this.id = rand()
    this.type = type
    this.line = line
    this.data = data
    this.parent = parent
    this.children = [] as Node[]
  }

  public toString(): string {
    if (this.type === NODE_TYPE.ROOT) return ''
    return this.data.toString()
  }

  public clone(): Node {
    const c = new Node(this.type, this.line, this.data, this.parent)
    c.id = this.id
    c.children = [...this.children]
    c.collapsed = this.collapsed
    if (isClonable(this.data)) {
      c.data = this.data.clone()
    }
    return c
  }

  public isComplete(): boolean {
    if (this.type === NODE_TYPE.TASK) {
      return (this.data as Task).isComplete()
    }
    return false
  }

  public isRoot(): boolean {
    return this.type === NODE_TYPE.ROOT
  }

  public isHeading(): boolean {
    return this.type === NODE_TYPE.HEADING
  }

  public isMemberOfHeading(): boolean {
    if (this.isRoot()) return false
    if (this.parent.isHeading()) return true
    return this.parent.isMemberOfHeading()
  }

  public append(node: Node): Node {
    const [cloned] = clone([this])
    const flatten = flat(this)
    node.line = flatten.length + 1
    node.parent = cloned
    cloned.children.push(node)
    return cloned
  }

  public insertEmptyTask(line: number): { root: Node; inserted: Node } {
    const [cloned] = clone([this])
    const found = cloned.find((n) => n.line === line)
    const empty = new Node(NODE_TYPE.TASK, 0, Task.parse(TASK_DEFAULT))
    if (found) {
      if (found.type === NODE_TYPE.HEADING || found.children.length > 0) {
        // Add as a child.
        empty.parent = found
        found.children.unshift(empty)
      } else {
        // Add as a sibling to the Node.
        empty.parent = found.parent
        const idx = found.parent.children.findIndex((n) => n.id === found.id)
        found.parent.children.splice(idx + 1, 0, empty)
      }
    }
    updateLineNumber(cloned)
    return { root: cloned, inserted: empty }
  }

  public appendEmptyTask(predicate: Predicate): Node {
    let cloned: Node
    const parent = this.find(predicate)
    if (parent) {
      const empty = new Node(NODE_TYPE.TASK, 0, Task.parse(TASK_DEFAULT))
      const newParent = parent.append(empty)
      cloned = this.replace(newParent, (n) => n.id === parent.id, false)
    }
    updateLineNumber(cloned)
    return cloned
  }

  public find(predicate: Predicate): Node | null {
    const queue: Node[] = [this]

    // breadth first search
    try {
      while (queue.length > 0) {
        const elm = queue.shift()
        if (predicate(elm)) {
          return elm
        }
        if (elm.children.length > 0) {
          queue.push(...elm.children)
        }
      }
    } catch (e) {
      Log.w(e)
    }
    return null
  }

  public each(callback: Callback): void {
    const queue: Node[] = [this]

    // breadth first search
    try {
      while (queue.length > 0) {
        const elm = queue.shift()
        callback(elm)
        if (elm.children.length > 0) {
          queue.push(...elm.children)
        }
      }
    } catch (e) {
      Log.w(e)
    }
  }

  public filter(predicate: Predicate): Node {
    const [cloned] = clone([this])
    const queue: Node[] = [cloned]
    const flatten: Node[] = []

    // breadth first search
    try {
      while (queue.length > 0) {
        const elm = queue.shift()
        flatten.push(elm)
        if (elm.children.length > 0) {
          queue.push(...elm.children)
        }
      }

      const reverse = flatten.reverse()

      // Remove nodes
      reverse.forEach((n) => {
        const match = predicate(n)
        if (!match) {
          // remove a node
          const parent = n.parent
          const idx = parent.children.findIndex((c) => c.id === n.id)
          parent.children = parent.children.filter((c) => c.id !== n.id)

          if (n.children.length > 0) {
            // replace a parent node
            parent.children.splice(idx, 0, ...n.children)
            n.children.forEach((c) => (c.parent = parent))
          }
        }
      })

      // Update line number
      updateLineNumber(cloned)
    } catch (e) {
      Log.w(e)
    }

    return cloned
  }

  public replace(node: Node, predicate: Predicate, keepChildren = true): Node {
    const [cloned] = clone([this])
    const target = cloned.find(predicate)
    if (target == null) return cloned
    const parent = target.parent
    if (parent == null) return cloned

    // keep some data of the node.
    node.id = target.id
    node.line = target.line
    node.parent = parent
    if (keepChildren) {
      node.children = target.children
    }

    parent.children = parent.children.map((n) => {
      return n.id === node.id ? node : n
    })

    return cloned
  }

  public size(): number {
    let size = 1
    size += this.children.reduce((acc, child) => acc + child.size(), 0)
    return size
  }
}

function clone(nodes: Node[], parent?: Node): Node[] {
  return nodes.map<Node>((a) => {
    const b = a.clone()
    b.parent = parent
    b.children = clone(b.children, b)
    return b
  })
}

/**
 * Update line number
 */
function updateLineNumber(root: Node): void {
  const flatten = flat(root)
  flatten.forEach((f, index) => {
    f.node.line = index + 1
  })
}

export function nodeToString(root: Node): string {
  const flatten = flat(root)
  const lines = flatten.map((item) => {
    if (item.node.type === NODE_TYPE.TASK) {
      let task = item.node.data as Task
      task = task.clone()
      item.node = item.node.clone()
      item.node.data = task
    }
    const indent = depthToIndent(item.depth)
    return `${indent}${item.node.toString()}`
  })

  return lines.join('\n')
}

/**
 * Get collapsed line numbers.
 */
export function getCollapsedLines(root: Node): number[] {
  const flatten = flat(root)
  return flatten.reduce((collapsed, n) => {
    if (n.node.collapsed) {
      collapsed.push(n.node.line)
    }
    return collapsed
  }, [])
}

export function nodeToTasks(root: INode, completed: boolean): Task[] {
  let tasks: Task[] = flat(root)
    .filter((n) => n.node.type === NODE_TYPE.TASK)
    .map((n) => n.node.data) as Task[]
  if (completed) {
    tasks = tasks.filter((t) => t.isComplete())
  }
  return tasks
}

export function setNodeByLine(root: Node, line: number, node: Node): Node {
  let newRoot: Node
  if (line > flat(root).length) {
    newRoot = root.append(node)
  } else if (node) {
    newRoot = root.replace(node, (n) => n.line === line)
  } else {
    // remove this line
    newRoot = root.filter((n) => n.line !== line)
  }
  return newRoot
}