]> Sergey Matveev's repositories - zk.git/blob - zk
5179aa3b12e5abf80403c267065792a8032caec4
[zk.git] / zk
1 #!/usr/bin/env perl
2 # zk -- zettelkästen/wiki/static website helper/generator
3 # Copyright (C) 2022-2025 Sergey Matveev <stargrave@stargrave.org>
4
5 =pod
6
7 =encoding utf-8
8
9 =head1 SYNOPSIS
10
11 zk -- zettelkästen/wiki/static website helper/generator
12
13 =head1 USAGE
14
15 =over
16
17 =item *
18
19 Use plain text files. One file per note/record.
20
21 =item *
22
23 Use file names that are friendly to vi editor word boundary determination.
24
25 =item *
26
27 You can safely place that notes in subdirectories hierarchy.
28
29 =item *
30
31 Edit and view your notes from their root path (zettelkästen root). That
32 way if you press C<gf> on F<Foo/Bar/Baz> word in Vim, then it will open
33 F<Foo/Bar/Baz> file in current window.
34
35 =item *
36
37 Use C<expand("%:r")> function to get current file path relative to the root.
38
39 =item *
40
41 Place links to another notes inside square brackets.
42 You can highlight them with:
43
44     syntax region zkLink start="\[\S" end="\S]"
45     highlight link zkLink String
46
47 =item *
48
49 Link to the directory's index can be made with C<[Dir/]>.
50
51 =item *
52
53 Use Vim's filename completion (C<:help compl-filename>) to complete
54 directories and filenames in them.
55
56 =item *
57
58 L<fzf|https://github.com/junegunn/fzf> related tools can be used to
59 navigate among existing notes.
60
61 =item *
62
63 Ordinary C<grep>, C<git-jump> or similar tools can be used to search and
64 quickly open results in the editor.
65
66 =item *
67
68 C<=E<gt> URL[ optional text]\r> on a separate line will add a link to the
69 specified URL (with optional text). Pay attention that line contains
70 carriage-return character at the end.
71
72 =item *
73
74 C<I URL[ alt]\r> on a separate line will add an image with
75 optional alternative text similarly as link is specified.
76
77 =back
78
79 The only thing Vim lacks there is ability to tell who backlinks
80 to the specified page. zk can be used to show what pages backlinks to
81 specified page and what pages are referenced by it:
82
83     $ zk links some/page
84     Another/Page
85     SomePage
86     $ zk backs some/page
87     [...]
88
89 That can be used to make categories and tags on notes. If note contains
90 a link to category/tag (even an empty file), then it will be backlinked.
91
92     $ zk htmls path/to/dir
93
94 Will convert all your notes to HTMLs with properly created links
95 to other pages. It also will include all backlinks in them. Each
96 directory will also contain index page with links to all
97 existing pages in current directory and to subdirectories.
98
99 =head1 (BACK)LINKS GENERATION
100
101 Set C<ZK_DO_LINKS=0> to disable automatic links-table creation in the
102 HTML output. Set C<ZK_DO_BACKS=0> to disable automatic backlinks-table.
103 If C<do-links\r> line is presented, then links-table is forcefully generated.
104 C<do-backs\r> generated backlinks table.
105
106 =head1 IGNORED FILES
107
108 You can ignore specified files processing by placing regular expressions
109 on separate lines in .zkignore file. They are applied to full relative
110 path of walked files.
111
112 =head1 HISTORY
113
114 Older version of that script, written on Z shell, can be found in Git history.
115
116 =cut
117
118 use strict;
119 use warnings;
120
121 sub usage {
122     print STDERR "Usage:
123 \t$0 links PAGE
124 \t$0 backs PAGE
125 \t$0 htmls DIR
126 \t$0 dot | dot -Tpng >png
127 ";
128     exit 1;
129 }
130
131 usage if $#ARGV == -1;
132
133 my $doLinks = ((not exists $ENV{ZK_DO_LINKS}) or
134     ($ENV{ZK_DO_LINKS} eq "1")) ? 1 : 0;
135 my $doBacks = ((not exists $ENV{ZK_DO_BACKS}) or
136     ($ENV{ZK_DO_BACKS} eq "1")) ? 1 : 0;
137
138 my %mtimes;
139 my %sizes;
140 my %cats;
141
142 {
143     my @ignores;
144     if (-e ".zkignore") {
145         open my $fh, "<", ".zkignore" or die "$!";
146         while (<$fh>) {
147             chop;
148             push @ignores, $_;
149         }
150         close $fh;
151     }
152     sub isignored {
153         my $p = shift;
154         foreach my $re (@ignores) {
155             return 1 if $p =~ m/$re/;
156         }
157         return 0;
158     }
159     use File::Find;
160     use POSIX qw(strftime);
161     sub wanted {
162         my $fn = $_;
163         return if ($fn =~ /^\./) and ($fn ne ".");
164         my $pth = $File::Find::name;
165         $pth =~ s/^\.\/?//;
166         if (-d $fn) {
167             return if isignored "$pth/";
168             opendir(my $dh, $fn) or die "$!";
169             my @entries;
170             while (readdir $dh) {
171                 next if /^\./;
172                 if (-d "$fn/$_") {
173                     $_ .= "/";
174                 }
175                 next if isignored(($fn eq ".") ? $_ : "$fn/$_");
176                 push @entries, $_;
177             }
178             closedir $dh;
179             $cats{$pth} = \@entries;
180         } else {
181             return if isignored $pth;
182             my @s = stat($fn) or die "$!";
183             $sizes{$pth} = $s[7];
184             $mtimes{$pth} = strftime "%Y-%m-%d %H:%M:%S", gmtime $s[9];
185         }
186     }
187     my %opts = (wanted => \&wanted, follow => 1);
188     find(\%opts, ".");
189 }
190
191 sub indexless {
192     $_ = shift;
193     /(.*\/)index$/;
194     return (defined $1) ? $1 : $_;
195 }
196
197 my %links;
198 my %backs;
199 for my $pth (keys %mtimes) {
200     my %found;
201     open(my $fh, "<", $pth) or die "$!";
202     while (<$fh>) {
203         foreach my $w (split /\s+/) {
204             next unless $w =~ /\[([^]]+)\]/;
205             $w = $1;
206             if ($w =~ /\/$/) {
207                 my $w = substr $w, 0, -1;
208                 if (not exists $cats{$w}) {
209                     print "missing $w\n" if exists $ENV{ZK_PRINT_MISSING};
210                     next;
211                 }
212             } else {
213                 if (not exists $mtimes{$w}) {
214                     print "missing $w\n" if exists $ENV{ZK_PRINT_MISSING};
215                     next;
216                 }
217             }
218             $found{$w} = 1;
219         }
220     }
221     close $fh;
222     my @ws = sort keys %found;
223     next if $#ws == -1;
224     $links{indexless $pth} = \@ws;
225     foreach (@ws) {
226         if (not exists $backs{$_}) {
227             my %h;
228             $backs{$_} = \%h;
229         }
230         $backs{$_}{$pth} = 1;
231     }
232 }
233
234 sub startHead {
235     my $out = shift;
236     my $title = shift;
237     print $out "<!DOCTYPE html>
238 <html><head>
239 <title>$title</title>
240 <meta http-equiv=\"Content-Type\" content=\"text/html; charset=utf-8\">
241 ";
242 }
243
244 sub htmlescape {
245     $_ = shift;
246     s/&/\&amp;/g;
247     s/</\&lt;/g;
248     s/>/\&gt;/g;
249     return $_;
250 }
251
252 use File::Spec;
253 use File::Basename;
254
255 sub genHTML {
256     my $out = shift;
257     my $page = shift;
258     my @lnks = exists $links{indexless $page} ? @{$links{indexless $page}} : ();
259     my @rels;
260     {
261         my $rel;
262         foreach (@lnks) {
263             $rel = File::Spec->abs2rel($_, $page);
264             $rel = (length $rel > 2) ? (substr $rel, 3) : "";
265             if (-d $_) {
266                 if ($rel ne "") {
267                     $rel .= "/";
268                 }
269                 $rel .= "index";
270             }
271             push @rels, $rel;
272         }
273     }
274     startHead $out, indexless $page;
275     {
276         my $fn = basename $page;
277         print $out "<link rel=\"alternate\" type=\"text/plain\" title=\"src\" href=\"$fn.txt\" />\n";
278     }
279     print $out "</head><body><pre>";
280     my $doLinksForced = 0;
281     my $doBacksForced = 0;
282     open(my $fh, "<", $page) or die "$!";
283     while (<$fh>) {
284         chop;
285         if (/\r$/) {
286             chop;
287             s/^(\s*)//g;
288             my $head = $1;
289             my @cols = split /\s+/;
290             if ($cols[0] eq "=>") {
291                 my $t = ($#cols > 1) ? (join " ", @cols[2..$#cols]) : $cols[1];
292                 $t = htmlescape $t;
293                 $t =~ s/"/\&guot;/g;
294                 $_ = "${head}=&gt; <a href=\"$cols[1]\">$t</a>";
295             } elsif ($cols[0] eq "I") {
296                 if ($#cols > 1) {
297                     my $t = htmlescape join " ", @cols[2..$#cols];
298                     $t =~ s/"/\&guot;/g;
299                     $_ = "<img src=\"$cols[1]\" alt=\"$t\" />";
300                 } else {
301                     $_ = "<img src=\"$cols[1]\" />";
302                 }
303             } elsif ($cols[0] eq "do-links") {
304                 $doLinksForced = 1;
305                 next;
306             } elsif ($cols[0] eq "do-backs") {
307                 $doBacksForced = 1;
308                 next;
309             } else {
310                 die "unknown $cols[0] command: $page\n";
311             }
312         } else {
313             $_ = htmlescape $_;
314             if (/\[.+\]/) {
315                 while (my ($i, $l) = each @lnks) {
316                     s/\[\Q$l\E\]/<a href="$rels[$i].html">[$l]<\/a>/g;
317                 }
318             }
319         }
320         print $out "$_\n";
321     }
322     close $fh;
323     print $out "</pre>\n";
324     if ($doLinksForced or ($doLinks and $#lnks != -1)) {
325         print $out "<a id=\"links\"></a><table border=1><caption>Links</caption>\n";
326         my $mtime;
327         while (my ($i, $l) = each @lnks) {
328             $mtime = (exists $mtimes{$l}) ? $mtimes{$l} : "";
329             print $out "<tr><td><a href=\"$rels[$i].html\">$l</a></td>
330     <td><tt>$mtime</tt></td></tr>\n";
331         }
332         print $out "</table>\n";
333     }
334     @lnks = sort keys %{$backs{indexless $page}};
335     if ($doBacksForced or ($doBacks and $#lnks != -1)) {
336         print $out "<a id=\"backs\"></a><table border=1><caption>Backlinks</caption>\n";
337         my $rel;
338         foreach my $l (@lnks) {
339             $rel = File::Spec->abs2rel($l, $page);
340             $rel = substr $rel, 3;
341             print $out "<tr><td><a href=\"$rel.html\">$l</a></td>
342     <td><tt>$mtimes{$l}</tt></td></tr>\n";
343         }
344         print $out "</table>\n";
345     }
346     print $out "</body></html>\n";
347 }
348
349 sub genIndex {
350     my $out = shift;
351     my $page = shift;
352     startHead $out, "$page/";
353     print $out "</head><body><table border=1>\n";
354     my @lnks = sort @{$cats{$page}};
355     foreach my $l (@lnks) {
356         next if $l =~ /\/$/;
357         my $pth = ($page eq "") ? $l : "$page/$l";
358         print $out "<tr><td><a href=\"$l.html\">$pth</a></td>
359     <td><tt>$mtimes{$pth}</tt></td><td>$sizes{$pth} B</td></tr>\n";
360     }
361     print $out "</table>\n";
362     @lnks = grep { /\/$/ } @lnks;
363     if ($#lnks != -1) {
364         print $out "<a id=\"cats\"></a><table border=1><caption>Subcategories</caption>\n";
365         foreach my $l (@lnks) {
366             $l = substr $l, 0, -1;
367             my $pth = ($page eq "") ? $l : "$page/$l";
368             my @entries = @{$cats{$pth}};
369             my $ctr = 1 + $#entries;
370             print $out "<tr><td><a href=\"$l/index.html\">$pth</a></td>
371     <td>$ctr</td></tr>\n"
372         }
373         print $out "</table>\n";
374     }
375     @lnks = sort keys %{$backs{"$page/"}};
376     if ($#lnks != -1) {
377         print $out "<a id=\"backs\"></a><table border=1><caption>Backlinks</caption>\n";
378         my $rel;
379         foreach my $l (@lnks) {
380             $rel = File::Spec->abs2rel($l, $page);
381             print $out "<tr><td><a href=\"$rel.html\">$l</a></td>
382     <td><tt>$mtimes{$l}</tt></td><td>$sizes{$l} B</td></tr>\n";
383         }
384         print $out "</table>\n";
385     }
386     print $out "</body></html>\n"
387 }
388
389 if ($ARGV[0] eq "dump") {
390     use Data::Dumper;
391     print Data::Dumper->Dump([\%links, \%backs, \%cats], [qw(*links *backs *cats)]);
392 } elsif ($ARGV[0] eq "links") {
393     map { print "$_\n" } @{$links{$ARGV[1]}};
394 } elsif ($ARGV[0] eq "backs") {
395     map { print "$_\n" } sort keys %{$backs{$ARGV[1]}};
396 } elsif ($ARGV[0] eq "html") {
397     genHTML \*STDOUT, $ARGV[1];
398 } elsif ($ARGV[0] eq "html-index") {
399     genIndex \*STDOUT, $ARGV[1];
400 } elsif ($ARGV[0] eq "htmls") {
401     my $now = time;
402     use File::Path qw(make_path);
403     use File::Copy;
404     foreach my $cat (keys %cats) {
405         make_path "$ARGV[1]/$cat";
406         next if (exists $mtimes{"$cat/index"});
407         my $fn = "$ARGV[1]/$cat/index.html";
408         open(my $fh, ">", $fn) or die "$!";
409         genIndex $fh, $cat;
410         close $fh;
411         utime $now, $now, $fn;
412     }
413     my @s;
414     foreach my $pth (keys %mtimes) {
415         open(my $fh, ">", "$ARGV[1]/$pth.html") or die "$!";
416         genHTML $fh, $pth;
417         close $fh;
418         @s = stat($pth) or die "$!";
419         utime $s[9], $s[9], "$ARGV[1]/$pth.html";
420         copy $pth, "$ARGV[1]/$pth.txt" or die "$!";
421         utime $s[9], $s[9], "$ARGV[1]/$pth.txt";
422     }
423 } elsif ($ARGV[0] eq "dot") {
424     print "digraph d {\n";
425     while (my ($from, $v) = each %links) {
426         foreach (@{$v}) {
427             print "\t\"$from\" -> \"$_\"\n";
428         }
429     }
430     print "}\n";
431 } else {
432     usage;
433 }