Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,21 @@ versioning. Build-time design rationale lives in

## [3.0.2] β€” 2026-07-02

### Fixed

- **`journal index` now recovers unindexed voice notes.** When a `journal log`
note lands but land-time indexing fails (e.g. Ollama down), the note prints
"run `journal index` to index later" β€” but `journal index` never scanned the
voice-note landing zone (`log.landing.dir`, default `logs/`), so the note
stayed permanently unindexed and invisible to `journal search --source
voice`. `journal index` (and `--rebuild`) now walks the landing zone and
indexes any voice note through the same `source=voice` chunking the
land-time path uses, with the same stable-hash change detection so
unchanged voice notes are a no-op on re-index. The landing zone is also now
excluded from the plain-note walk (matching how the transcripts landing
zone is already handled) so a voice note isn't double-indexed as a regular
note.

### Added

- **GUI launchers can now find the journal without shell environment.** Two
Expand Down
35 changes: 33 additions & 2 deletions cmd/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/ericmann/journal/internal/config"
"github.com/ericmann/journal/internal/embed"
"github.com/ericmann/journal/internal/index"
jlog "github.com/ericmann/journal/internal/log"
"github.com/ericmann/journal/internal/quill"
"github.com/ericmann/journal/internal/store"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -69,13 +70,14 @@ func runWatch(ctx context.Context, cfg *config.Config, e embed.Embedder, out io.
}

// noteExcludes is the exclude set for the NOTE walk: the configured excludes
// plus the transcripts landing zone (transcripts are indexed via a separate
// source-tagged path, never as notes).
// plus the transcripts landing zone and the voice-note landing zone (both are
// indexed via separate source-tagged paths, never as plain notes).
func noteExcludes(cfg *config.Config) []string {
ex := append([]string{}, cfg.Excludes...)
if cfg.Transcripts.Enabled {
ex = append(ex, cfg.TranscriptsRelPath()+"/**")
}
ex = append(ex, cfg.LogRelPath()+"/**")
return ex
}

Expand Down Expand Up @@ -165,6 +167,35 @@ func runIndex(ctx context.Context, cfg *config.Config, e embed.Embedder, opts in
}
}

// Voice notes land via `journal log` and are indexed at land time; when
// that fails (e.g. Ollama down) the note is left on disk and this is the
// recovery path. Reuse the same source=voice chunking as the land-time
// path, with the same stable-hash change detection, so re-running this
// on already-indexed voice notes is a no-op.
voiceFiles, verr := index.WalkVoice(cfg.Root(), cfg.LogRelPath(), since)
if verr != nil {
return st, fmt.Errorf("walking voice notes: %w", verr)
}
if len(voiceFiles) > 0 {
var vst index.Stats
for _, f := range voiceFiles {
content, rerr := index.ReadFile(f)
if rerr != nil {
return st, fmt.Errorf("reading voice note %s: %w", f.RelPath, rerr)
}
fst, ierr := jlog.IndexVoice(ctx, ix, f.RelPath, content, f.ModTime)
if ierr != nil {
return st, fmt.Errorf("indexing voice note %s: %w", f.RelPath, ierr)
}
vst.FilesScanned++
vst.Embedded += fst.Embedded
vst.Updated += fst.Updated
vst.Deleted += fst.Deleted
}
fmt.Fprintf(out, "indexed %d voice note(s): %d embedded, %d updated, %d deleted\n",
vst.FilesScanned, vst.Embedded, vst.Updated, vst.Deleted)
}

if cfg.GitAutocommit {
committed, cerr := index.AutoCommit(cfg.Root(), st, cfg.GitAutocommitSign, time.Now())
switch {
Expand Down
98 changes: 98 additions & 0 deletions cmd/index_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/ericmann/journal/internal/config"
"github.com/ericmann/journal/internal/embed"
"github.com/ericmann/journal/internal/store"
)

// testRepo creates an initialized journal repo with the given files and returns
Expand Down Expand Up @@ -108,6 +110,102 @@ func TestRunIndexRebuildReembeds(t *testing.T) {
}
}

// TestRunIndexRecoversUnindexedVoiceNote reproduces the issue #118 scenario:
// a voice note lands while indexing is unavailable (e.g. Ollama down), the
// user is told to run `journal index` to index later, and that recovery hint
// must actually work.
func TestRunIndexRecoversUnindexedVoiceNote(t *testing.T) {
cfg := testRepo(t, nil)
cfg.Log.Shaping.Enabled = false // skip shaping, pure raw land

// Force the land-time index step to fail (bad store path), leaving the
// voice note on disk but unindexed.
cfg.StorePath = "/nonexistent/bad.db"
fake := embed.NewFake(cfg.EmbedDim)
var landOut bytes.Buffer
if _, err := runLogText(context.Background(), cfg, fake, nil, "voice note stuck unindexed", &landOut); err != nil {
t.Fatal(err)
}
if !strings.Contains(landOut.String(), "index skipped") {
t.Fatalf("expected land-time index to fail, got: %q", landOut.String())
}

// Point the store back at the real (default) location and run the
// recovery command.
cfg.StorePath = filepath.Join(config.JournalDir, "index", "journal.db")
var indexOut bytes.Buffer
st, err := runIndex(context.Background(), cfg, fake, indexOptions{}, &indexOut)
if err != nil {
t.Fatal(err)
}
// The voice note lives under the log landing zone, which is excluded from
// the plain-note walk (it's indexed via the source-tagged voice path
// below, not re-chunked as a regular note).
if st.FilesScanned != 0 {
t.Errorf("plain note walk scanned %d files, want 0 (voice note should be excluded)", st.FilesScanned)
}
if !strings.Contains(indexOut.String(), "indexed 1 voice note(s):") {
t.Errorf("expected voice note indexing summary, got: %q", indexOut.String())
}

results, err := runSearch(context.Background(), cfg, fake, "voice note stuck unindexed", 5,
store.Filter{Sources: []string{store.SourceVoice}})
if err != nil {
t.Fatal(err)
}
if len(results) == 0 {
t.Error("search --source voice returned no results after recovery index")
}
}

// TestRunIndexVoiceNoopReindex confirms unchanged voice notes are a no-op on
// re-index, same as notes and transcripts.
func TestRunIndexVoiceNoopReindex(t *testing.T) {
cfg := testRepo(t, nil)
cfg.Log.Shaping.Enabled = false

fake := embed.NewFake(cfg.EmbedDim)
if _, err := runLogText(context.Background(), cfg, fake, nil, "unchanged voice note", &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
before := fake.EmbedTexts

if _, err := runIndex(context.Background(), cfg, fake, indexOptions{}, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if fake.EmbedTexts != before {
t.Errorf("no-op voice reindex made %d embed calls, want 0", fake.EmbedTexts-before)
}
}

// TestRunIndexRebuildReembedsVoiceNotes confirms --rebuild covers voice notes.
func TestRunIndexRebuildReembedsVoiceNotes(t *testing.T) {
cfg := testRepo(t, nil)
cfg.Log.Shaping.Enabled = false

fake := embed.NewFake(cfg.EmbedDim)
if _, err := runLogText(context.Background(), cfg, fake, nil, "rebuild me please", &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
before := fake.EmbedTexts

if _, err := runIndex(context.Background(), cfg, fake, indexOptions{rebuild: true}, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if fake.EmbedTexts <= before {
t.Errorf("rebuild made %d embed calls, want > 0 (voice note should be re-embedded)", fake.EmbedTexts-before)
}

results, err := runSearch(context.Background(), cfg, fake, "rebuild me please", 5,
store.Filter{Sources: []string{store.SourceVoice}})
if err != nil {
t.Fatal(err)
}
if len(results) == 0 {
t.Error("search --source voice returned no results after --rebuild")
}
}

func TestParseSince(t *testing.T) {
cases := []struct {
in string
Expand Down
30 changes: 26 additions & 4 deletions internal/index/walk.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,22 @@ type File struct {
// transcriptRel (relative to root), with RelPath repo-relative. Returns nil if
// the landing zone doesn't exist yet.
func WalkTranscripts(root, transcriptRel string, since time.Time) ([]File, error) {
dir := filepath.Join(root, filepath.FromSlash(transcriptRel))
return walkLandingDir(root, transcriptRel, []string{".md", ".txt", ".qm"}, since)
}

// WalkVoice returns voice-note files (.md β€” the only extension the land-time
// path ever writes, see log.Filename) under the landing zone logRel (relative
// to root), with RelPath repo-relative. Returns nil if the landing zone
// doesn't exist yet.
func WalkVoice(root, logRel string, since time.Time) ([]File, error) {
return walkLandingDir(root, logRel, []string{".md"}, since)
}

// walkLandingDir returns files with one of exts under a landing zone rel
// (relative to root), with RelPath repo-relative. Returns nil if the landing
// zone doesn't exist yet. Shared by the transcript and voice-note walks.
func walkLandingDir(root, rel string, exts []string, since time.Time) ([]File, error) {
dir := filepath.Join(root, filepath.FromSlash(rel))
if _, err := os.Stat(dir); os.IsNotExist(err) {
return nil, nil
}
Expand All @@ -33,7 +48,14 @@ func WalkTranscripts(root, transcriptRel string, since time.Time) ([]File, error
return nil
}
low := strings.ToLower(d.Name())
if !strings.HasSuffix(low, ".md") && !strings.HasSuffix(low, ".txt") && !strings.HasSuffix(low, ".qm") {
matched := false
for _, ext := range exts {
if strings.HasSuffix(low, ext) {
matched = true
break
}
}
if !matched {
return nil
}
info, infoErr := d.Info()
Expand All @@ -43,11 +65,11 @@ func WalkTranscripts(root, transcriptRel string, since time.Time) ([]File, error
if !since.IsZero() && info.ModTime().Before(since) {
return nil
}
rel, relErr := filepath.Rel(root, p)
relPath, relErr := filepath.Rel(root, p)
if relErr != nil {
return relErr
}
files = append(files, File{RelPath: filepath.ToSlash(rel), AbsPath: p, ModTime: info.ModTime()})
files = append(files, File{RelPath: filepath.ToSlash(relPath), AbsPath: p, ModTime: info.ModTime()})
return nil
})
if err != nil {
Expand Down
Loading