#!/usr/bin/env perl # zk -- zettelkästen/wiki/static website helper/generator # Copyright (C) 2022-2025 Sergey Matveev =pod =encoding utf-8 =head1 SYNOPSIS zk -- zettelkästen/wiki/static website helper/generator =head1 USAGE =over =item * Use plain text files. One file per note/record. =item * Use file names that are friendly to vi editor word boundary determination. =item * You can safely place that notes in subdirectories hierarchy. =item * Edit and view your notes from their root path (zettelkästen root). That way if you press C on F word in Vim, then it will open F file in current window. =item * Use C function to get current file path relative to the root. =item * Place links to another notes inside square brackets. You can highlight them with: syntax region zkLink start="\[\S" end="\S]" highlight link zkLink String =item * Link to the directory's index can be made with C<[Dir/]>. =item * Use Vim's filename completion (C<:help compl-filename>) to complete directories and filenames in them. =item * L related tools can be used to navigate among existing notes. =item * Ordinary C, C or similar tools can be used to search and quickly open results in the editor. =item * C<=E URL[ optional text]\r> on a separate line will add a link to the specified URL (with optional text). Pay attention that line contains carriage-return character at the end. =item * C on a separate line will add an image with optional alternative text similarly as link is specified. =back The only thing Vim lacks there is ability to tell who backlinks to the specified page. zk can be used to show what pages backlinks to specified page and what pages are referenced by it: $ zk links some/page Another/Page SomePage $ zk backs some/page [...] That can be used to make categories and tags on notes. If note contains a link to category/tag (even an empty file), then it will be backlinked. $ zk htmls path/to/dir Will convert all your notes to HTMLs with properly created links to other pages. It also will include all backlinks in them. Each directory will also contain index page with links to all existing pages in current directory and to subdirectories. =head1 (BACK)LINKS GENERATION Set C to disable automatic links-table creation in the HTML output. Set C to disable automatic backlinks-table. If C line is presented, then links-table is forcefully generated. C generated backlinks table. =head1 IGNORED FILES You can ignore specified files processing by placing regular expressions on separate lines in .zkignore file. They are applied to full relative path of walked files. =head1 HISTORY Older version of that script, written on Z shell, can be found in Git history. =cut use strict; use warnings; sub usage { print STDERR "Usage: \t$0 links PAGE \t$0 backs PAGE \t$0 htmls DIR \t$0 dot | dot -Tpng >png "; exit 1; } usage if $#ARGV == -1; my $doLinks = ((not exists $ENV{ZK_DO_LINKS}) or ($ENV{ZK_DO_LINKS} eq "1")) ? 1 : 0; my $doBacks = ((not exists $ENV{ZK_DO_BACKS}) or ($ENV{ZK_DO_BACKS} eq "1")) ? 1 : 0; my %mtimes; my %sizes; my %cats; { my @ignores; if (-e ".zkignore") { open my $fh, "<", ".zkignore" or die "$!"; while (<$fh>) { chop; push @ignores, $_; } close $fh; } sub isignored { my $p = shift; foreach my $re (@ignores) { return 1 if $p =~ m/$re/; } return 0; } use File::Find; use POSIX qw(strftime); sub wanted { my $fn = $_; return if ($fn =~ /^\./) and ($fn ne "."); my $pth = $File::Find::name; $pth =~ s/^\.\/?//; if (-d $fn) { return if isignored "$pth/"; opendir(my $dh, $fn) or die "$!"; my @entries; while (readdir $dh) { next if /^\./; if (-d "$fn/$_") { $_ .= "/"; } next if isignored(($fn eq ".") ? $_ : "$fn/$_"); push @entries, $_; } closedir $dh; $cats{$pth} = \@entries; } else { return if isignored $pth; my @s = stat($fn) or die "$!"; $sizes{$pth} = $s[7]; $mtimes{$pth} = strftime "%Y-%m-%d %H:%M:%S", gmtime $s[9]; } } my %opts = (wanted => \&wanted, follow => 1); find(\%opts, "."); } sub indexless { $_ = shift; /(.*\/)index$/; return (defined $1) ? $1 : $_; } my %links; my %backs; for my $pth (keys %mtimes) { my %found; open(my $fh, "<", $pth) or die "$!"; while (<$fh>) { foreach my $w (split /\s+/) { next unless $w =~ /\[([^]]+)\]/; $w = $1; if ($w =~ /\/$/) { my $w = substr $w, 0, -1; if (not exists $cats{$w}) { print "missing $w\n" if exists $ENV{ZK_PRINT_MISSING}; next; } } else { if (not exists $mtimes{$w}) { print "missing $w\n" if exists $ENV{ZK_PRINT_MISSING}; next; } } $found{$w} = 1; } } close $fh; my @ws = sort keys %found; next if $#ws == -1; $links{indexless $pth} = \@ws; foreach (@ws) { if (not exists $backs{$_}) { my %h; $backs{$_} = \%h; } $backs{$_}{$pth} = 1; } } sub startHead { my $out = shift; my $title = shift; print $out " $title "; } sub htmlescape { $_ = shift; s/&/\&/g; s//\>/g; return $_; } use File::Spec; use File::Basename; sub genHTML { my $out = shift; my $page = shift; my @lnks = exists $links{indexless $page} ? @{$links{indexless $page}} : (); my @rels; { my $rel; foreach (@lnks) { $rel = File::Spec->abs2rel($_, $page); $rel = (length $rel > 2) ? (substr $rel, 3) : ""; if (-d $_) { if ($rel ne "") { $rel .= "/"; } $rel .= "index"; } push @rels, $rel; } } startHead $out, indexless $page; { my $fn = basename $page; print $out "\n"; } print $out "
";
    my $doLinksForced = 0;
    my $doBacksForced = 0;
    open(my $fh, "<", $page) or die "$!";
    while (<$fh>) {
        chop;
        if (/
$/) {
            chop;
            s/^(\s*)//g;
            my $head = $1;
            my @cols = split /\s+/;
            if ($cols[0] eq "=>") {
                my $t = ($#cols > 1) ? (join " ", @cols[2..$#cols]) : $cols[1];
                $t = htmlescape $t;
                $t =~ s/"/\&guot;/g;
                $_ = "${head}=> $t";
            } elsif ($cols[0] eq "I") {
                if ($#cols > 1) {
                    my $t = htmlescape join " ", @cols[2..$#cols];
                    $t =~ s/"/\&guot;/g;
                    $_ = "\"$t\"";
                } else {
                    $_ = "";
                }
            } elsif ($cols[0] eq "do-links") {
                $doLinksForced = 1;
                next;
            } elsif ($cols[0] eq "do-backs") {
                $doBacksForced = 1;
                next;
            } else {
                die "unknown $cols[0] command: $page\n";
            }
        } else {
            $_ = htmlescape $_;
            if (/\[.+\]/) {
                while (my ($i, $l) = each @lnks) {
                    s/\[\Q$l\E\]/[$l]<\/a>/g;
                }
            }
        }
        print $out "$_\n";
    }
    close $fh;
    print $out "
\n"; if ($doLinksForced or ($doLinks and $#lnks != -1)) { print $out "\n"; my $mtime; while (my ($i, $l) = each @lnks) { $mtime = (exists $mtimes{$l}) ? $mtimes{$l} : ""; print $out "\n"; } print $out "
Links
$l $mtime
\n"; } @lnks = sort keys %{$backs{indexless $page}}; if ($doBacksForced or ($doBacks and $#lnks != -1)) { print $out "\n"; my $rel; foreach my $l (@lnks) { $rel = File::Spec->abs2rel($l, $page); $rel = substr $rel, 3; print $out "\n"; } print $out "
Backlinks
$l $mtimes{$l}
\n"; } print $out "\n"; } sub genIndex { my $out = shift; my $page = shift; startHead $out, "$page/"; print $out "\n"; my @lnks = sort @{$cats{$page}}; foreach my $l (@lnks) { next if $l =~ /\/$/; my $pth = ($page eq "") ? $l : "$page/$l"; print $out "\n"; } print $out "
$pth $mtimes{$pth}$sizes{$pth} B
\n"; @lnks = grep { /\/$/ } @lnks; if ($#lnks != -1) { print $out "\n"; foreach my $l (@lnks) { $l = substr $l, 0, -1; my $pth = ($page eq "") ? $l : "$page/$l"; my @entries = @{$cats{$pth}}; my $ctr = 1 + $#entries; print $out "\n" } print $out "
Subcategories
$pth $ctr
\n"; } @lnks = sort keys %{$backs{"$page/"}}; if ($#lnks != -1) { print $out "\n"; my $rel; foreach my $l (@lnks) { $rel = File::Spec->abs2rel($l, $page); print $out "\n"; } print $out "
Backlinks
$l $mtimes{$l}$sizes{$l} B
\n"; } print $out "\n" } if ($ARGV[0] eq "dump") { use Data::Dumper; print Data::Dumper->Dump([\%links, \%backs, \%cats], [qw(*links *backs *cats)]); } elsif ($ARGV[0] eq "links") { map { print "$_\n" } @{$links{$ARGV[1]}}; } elsif ($ARGV[0] eq "backs") { map { print "$_\n" } sort keys %{$backs{$ARGV[1]}}; } elsif ($ARGV[0] eq "html") { genHTML \*STDOUT, $ARGV[1]; } elsif ($ARGV[0] eq "html-index") { genIndex \*STDOUT, $ARGV[1]; } elsif ($ARGV[0] eq "htmls") { my $now = time; use File::Path qw(make_path); use File::Copy; foreach my $cat (keys %cats) { make_path "$ARGV[1]/$cat"; next if (exists $mtimes{"$cat/index"}); my $fn = "$ARGV[1]/$cat/index.html"; open(my $fh, ">", $fn) or die "$!"; genIndex $fh, $cat; close $fh; utime $now, $now, $fn; } my @s; foreach my $pth (keys %mtimes) { open(my $fh, ">", "$ARGV[1]/$pth.html") or die "$!"; genHTML $fh, $pth; close $fh; @s = stat($pth) or die "$!"; utime $s[9], $s[9], "$ARGV[1]/$pth.html"; copy $pth, "$ARGV[1]/$pth.txt" or die "$!"; utime $s[9], $s[9], "$ARGV[1]/$pth.txt"; } } elsif ($ARGV[0] eq "dot") { print "digraph d {\n"; while (my ($from, $v) = each %links) { foreach (@{$v}) { print "\t\"$from\" -> \"$_\"\n"; } } print "}\n"; } else { usage; }