File management from within Logseq, proof-of-concept

Table of Contents

  • Introduction
  • Screenshot
  • Current Features
  • Code

Introduction

This is motivated by @alex0’s description #2 about reusing Logseq’s outliner for file management. This implementation is:

  • Different on purpose:
    • Forget most conveniences and prepare to learn a different workflow.
    • It tries to blend with Logseq’s experience.
      • Logseq is different and this is too.
  • Experimental:
    • No specific direction, other than proving the concept
    • Highly UNTESTED
      • If you want quality, use instead the file manager of your operating system.
  • Missing important features. Here are only a few:
    • No consideration for access rights
    • No check for duplicate block entries
    • NO UNDO
    • Almost no safety:
      • Not easy to make a mess, but certainly possible
        • Use at own risk
      • It is technically a SECURITY RISK
        • Don’t use it in production

Screenshot

image


Current Features

  • Use a simple macro to turn any block into a node about your file system, e.g.:
    • {{foldername C:/pathto/parentfolder}}
    • Such blocks can be safely:
      • deleted:
        • no changes to the file system
      • moved:
        • position is irrelevant
      • copied:
        • no changes to the file system
        • each copy is independent
  • Hover to show the available buttons.
  • Safely click on any button to see if that path currently exists in the file system.
    • Simple clicks make no changes.
    • Alt + click proceeds to changes without confirmation.
      • Basic info is provided as messages and console logs.
  • Alt + click on button Read to auto-generate blocks in a hierarchy that matches the one currently on your drive.
    • This may take some time for big trees.
      • Try first with a small one.
    • Repeat to refresh:
      • The blocks are not synced with the file system.
      • Any outdated blocks get deleted.
        • Copies don’t get affected.
          • Can use this feature for comparisons.
  • Expand a folder-block to show its contents.
    • Nested blocks don’t get affected by expanding/collapsing.
      • They maintain their values since the last Read of any of their ancestor blocks.
  • Zoom on a block to focus as usually.
  • Alt + click on button Delete to delete both the underlying file/folder and block.
    • As mentioned earlier, deleting manually a block or file/folder doesn’t update its pair.
    • Subfolders and sub-blocks get also deleted.
  • Alt + click on button Create to create a missing folder and all of its missing ancestors (if any) recursively.
    • This doesn’t create files.

Code

  • At the end of file preload.js, normally inside ...\Logseq\app-0.9.13\resources\app\js:
    contextBridge.exposeInMainWorld('getfs', ()=>fs )
    
    • This is the security risk.
    • It also gets affected by updates.
  • Inside file config.edn, inside macros{}:
:filename "<div class='kit filesystem' data-kit='filesystem' data-type='file' data-name='$1'></div>"
:foldername "<div class='kit filesystem' data-kit='filesystem' data-type='folder' data-name='$1'></div>"
  • Inside file custom.css:
button.filesystem {
  font-size: 12px;
  line-height: 14px;
  margin: 0px 8px 0px 8px;
  padding: 2px 4px 2px 4px;
}
button.filesystem.create {
  background: #ffee00;
}
button.filesystem.delete {
  background: #ff4400;
}
button.filesystem.read {
  background: #ff9900;
}
div.block-content button.filesystem {
    visibility: hidden;
}
div.block-content:hover button.filesystem {
    visibility: visible;
}
  • This code requires having kits inside file custom.js .
  • Inside page FileSystem in Logseq, put the following code in one or more javascript code-block(s):
const LS = logseq.api
const Module = logseq.Module
const Kits = Module.Kits

function statusMsg(status, msg){
    Module.Msg.ofStatus(msg, status)
}
const error = statusMsg.bind(null, "error")
const info = statusMsg.bind(null, "info")
const success = statusMsg.bind(null, "success")

const fs = window.getfs && getfs()
if (!fs) return error("missing fs")

const Block = (Module.Block || Module.setChild("Block"))
.setStatic(function forEachChild(block, cb){
    block.children.find( (arr)=>{
        const child = LS.get_block(arr[1])
        if (child) return cb(child)
    })
})
.setStatic(function parentOf(block){
    return LS.get_block(block.parent.id)
})

const FS = Module.setChild("FileSystem")
.setStatic(function button(name, handler, arg){
    const btn = Kits.createElementOfClass("button", "filesystem", name)
    btn.classList.add(name.toLowerCase())
    Kits.addClickEventToButton(handler.bind(null, arg), btn)
    return btn
})
.setStatic(function create(block, cb){
    const parent = Block.parentOf(block)
    if (!parent) return cb("exists")

    create(parent, (res)=>{
        if (res !== "done" && res !== "exists") return cb(res)

        const path = FS.fullPathOfBlock(block)
        fs.access(path, (err)=>{
            if (!err) return cb("exists")

            fs.mkdir(path, (err)=>{
                if (!err) console.log("CREATED FOLDER: " + path)
                cb(err ? err.message : "done")
            })
        })
    })
})
.setStatic(function deleteMissingBlockChildren(block, cb){
    const rest = []
    var rem = 0
    Block.forEachChild(block, (child)=>{
        rem += 1
        fs.access(FS.fullPathOfBlock(child), (err)=>{
            if (err) {
                LS.remove_block(child.uuid)
                console.log("DELETED BLOCK: " + FS.nameOfBlock(child))
            } else {
                rest.push(child)
            }
            if (!--rem) cb(rest)
        })
    })
    if (!rem) cb(rest)
})
.setStatic(function fullPathOfBlock(block){
    const name = FS.nameOfBlock(block)
    return (!name) ? "" : FS.pathOfBlock(Block.parentOf(block)) + name
})
.setStatic(function insertChildBlock(parentBlock, path, name){
    const property = FS.isFolder(path + "/" + name) ? "foldername" : "filename"
    const nameblock = {properties: {[property]: name}}

    var found
    Block.forEachChild(parentBlock, (sibling)=>{
        if (FS.nameCompare(sibling, nameblock) > -1) return found = sibling
    })
    if (found && !FS.nameCompare(found, nameblock)) return found.uuid

    const customUUID = LS.new_block_uuid()
    const properties = {[property]: name}
    const options = {customUUID, properties}
    if (found) {
        options.before = true
        options.sibling = true
    }
    const content = "{{" + property + " " + name + "}}"
    const uuid = (found || parentBlock).uuid
    LS.insert_block(uuid, content, options)
})
.setStatic(function isFolder(path){
    try { fs.readdirSync(path) }
    catch { return false }
    return true
})
.setStatic(function nameCompare(a, b){
    const aprops = a.properties
    const bprops = b.properties

    const aname = aprops.filename
    const bname = bprops.filename
    if (aname && !bname) return 1
    if (!aname && bname) return -1

    const prop = (aname) ? "filename" : "foldername"
    return aprops[prop].toLowerCase().localeCompare(bprops[prop].toLowerCase())
})
.setStatic(function nameOfBlock(block){
    const properties = block.properties
    return properties.foldername || properties.filename
})
.setStatic(function nameOfErr(err){
	return err.message.slice(0, err.message.indexOf(":"))
})
.setStatic(function onCreateClicked(blockId, e){
    const block = LS.get_block(blockId)
    fs.access(FS.fullPathOfBlock(block), (err)=>{
        if (!err) return info("exists")

        if (e.altKey) FS.create(block, FS.onCreated)
        else info("Alt to create")
    })
})
.setStatic(function onCreated(res){
    if (res === "done") success("created")
    else if (res === "exists") error("root should exist") // special
    else error(res)
})
.setStatic(function onDeleteClicked(blockId, e){
    const block = LS.get_block(blockId)
    const fullPath = FS.fullPathOfBlock(block)
    fs.access(fullPath, (err)=>{
        if (err) return info("doesn't exist")

        if (e.altKey) FS.unlink(fullPath, block)
        else info("Alt to delete")
    })
})
.setStatic(function onReadClicked(blockId, e){
    const block = LS.get_block(blockId)
    fs.access(FS.fullPathOfBlock(block), (err)=>{
        if (err) return info("doesn't exist")

        if (e.altKey) FS.updateTreeBlock(block)
        else info("Alt to read")
    })
})
.setStatic(function onTreeBlockUpdated(spanIcon, iconHtml, res){
    spanIcon.innerHTML = iconHtml
    if (res === "done") success("done")
    else error(res)
})
.setStatic(function onUnlinkFinished(type, path, block, err){
    if (err) return error(FS.nameOfErr(err) + ": " + path)

    console.log("DELETED " + type + ": " + path)
    success("deleted")
    LS.remove_block(block.uuid)
    console.log("DELETED BLOCK: " + FS.nameOfBlock(block))
})
.setStatic(function pathOfBlock(block){
    const foldername = block && block.properties.foldername
    return (!foldername) ? "" : pathOfBlock(Block.parentOf(block)) + foldername + "/"
})
.setStatic(function sortBlocks(blocks){
    blocks.sort(FS.nameCompare)

    var previous
    const options = {sibling: true}
    blocks.forEach( (block)=>{
        if (previous) {
            LS.move_block(block.uuid, previous.uuid, options)
        }
        previous = block
    })
})
.setStatic(function unlink(path, block){
    if (FS.isFolder(path)) {
        const cb = FS.onUnlinkFinished.bind(null, "FOLDER", path, block)
        fs.rmdir(path, {recursive: true, force: true}, cb)
    } else {
        const cb = FS.onUnlinkFinished.bind(null, "FILE", path, block)
        fs.unlink(path, cb)
    }
})
.setStatic(function updateChildren(parentBlock, cb){
    var rem = 0
    Block.forEachChild(parentBlock, (child)=>{
        if (!child.properties.foldername) return;

        rem += 1
        FS.updateFolderBlock(child, ()=>{ if (!--rem) cb("done") } )
    })
    if (!rem) cb("done")
})
.setStatic(function updateDiv(div, blockId){
    const divParent = div.closest(".block-content")
    divParent.querySelector(".block-content-inner").remove()
    const divProps = divParent.querySelector(".block-properties div")

    const block = LS.get_block(blockId)
    const foldername = block.properties.foldername
    const spanIcon = Kits.createElementOfClass("span", "ti")
    spanIcon.innerHTML = (foldername) ? "&#xeaad" : "&#xeaa2"

    divProps.firstChild.remove()
    divProps.firstChild.remove()
    divProps.prepend(spanIcon, " ")
    if (foldername) divProps.append(
        FS.button("Create", FS.onCreateClicked, blockId),
        FS.button("Read", FS.onReadClicked, blockId)
    )
    divProps.append( FS.button("Delete", FS.onDeleteClicked, blockId) )
})
.setStatic(function updateFolderBlock(block, cb){
    const path = FS.fullPathOfBlock(block)
    const uuidParent = block.uuid
    fs.readdir(path, (err, names)=>{
        if (err) return cb(FS.nameOfErr(err) + ": " + path)

        FS.deleteMissingBlockChildren(block, (rest)=>{
            if (!names.length) return cb("done");

            if (rest.length) FS.sortBlocks(rest)
            names.forEach( (name)=>{
                FS.insertChildBlock(block, path, name)
                block = LS.get_block(uuidParent)
            })
            if (!rest.length) LS.set_block_collapsed(uuidParent, true)

            FS.updateChildren(block, cb)
        })
    })
})
.setStatic(function updateTreeBlock(block){
    const selector = "div.ls-block [blockid='" + block.uuid + "'] .ti"
    const spanIcon = document.querySelector(selector)
    const cb = FS.onTreeBlockUpdated.bind(null, spanIcon, spanIcon.innerHTML)
    spanIcon.innerHTML = "&#xeaae"
    FS.updateFolderBlock(block, cb)
})

logseq.kits.setStatic(function filesystem(div){
    const type = div.dataset.type
    const name = div.dataset.name
    const blockId = div.closest(".ls-block").getAttribute("blockid")
    LS.upsert_block_property(blockId, type + "name", name)
    setTimeout(FS.updateDiv, 0, div, blockId) // wait UI
})