C言語で ミニ日記アプリを作ろう!(6):Ver.5からVer.6へ  ~文字カウント仕様の追加に伴う大幅改編(UTF-8対応・複数行入力・100文字制限)~

スポンサーリンク
C言語

前回は、 Ver.5を作成した。

TL;DR(要約)

  • Ver.5 は「1行入力+保存+閲覧(過去2件/月次)」のシンプル版。  
  • Ver.6 は複数行入力(空行で終了)、UTF-8の“文字数”カウント、20〜100文字の制約を追加。  
  • 文字カウント仕様の追加により、ロケール初期化・マルチバイト処理・入力ループを中心に大改編。  
  • コンパイル方法も変更:POSIX マクロ定義で警告ゼロを推奨。  

検証環境
PaizaCloud(無料プラン)
Distributor ID: Ubuntu
Description: Ubuntu 18.04.3 LTS
Release: 18.04
gcc (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0

背景:Ver.5 の仕様と課題

Ver.5 の仕様

  • 1行入力(`fgets` で1行だけ受け取り保存)
  • メニュー:過去2件表示/新規記録/月ごとの一覧/終了
  • 保存形式YYYY-MM-DD(曜) hh:mm:ss]` の行+本文1行+空行  
  • 月次一覧:タイムスタンプの先頭7文字(`YYYY-MM`)でグルーピング
  • ロケール`setlocale(LC_ALL, “ja_JP.UTF-8”)` を固定指定(環境によって失敗することがある)

Ver.5 の課題

  • 複数行を書けない(空行で終了といった UI がない)  
  • 文字数の扱いが“バイト数”寄りで、日本語(UTF-8)の「1文字」と一致しない可能性  
  • ロケールを固定名で指定するため、環境差(該当ロケール未導入)で不安定になり得る

Ver.6 の仕様追加

  • 複数行入力(空行で終了)
  • 入力のたびに追記後の「現在の文字数」を表示
  • 20〜100文字の制約  
    20文字未満 → 保存不可  
    100文字超 →超過分は保存しない(その行の取り込みを制限)  
  • UTF-8 での“文字数”カウント(日本語の 1 文字 ≠ バイト数)
  • ロケール初期化の堅牢化(環境差で壊れない)

設計の要点

  • 「文字数」の定義をUnicode の“文字”(コードポイント)寄りにし、`mbrtowc` を用いて UTF-8 のマルチバイトを1文字として数える  
  • 改行はカウントしない(仕様として固定)  
  • ロケールは `setlocale(LC_ALL, “”)` を起点に UTF-8 を確保(`ja_JP.UTF-8` → `C.UTF-8` → `en_US.UTF-8` フォールバック)  
  • mbrtowc` エラー時にループを打ち切らず、1バイト進めて復帰(混入バイトで全体が 0 文字にならないように)  
  • 保存形式は Ver.5 と互換(`[timestamp]` → 本文 → 空行)
    既存 `diary.txt` をそのまま利用可
#define _POSIX_C_SOURCE 200809L  // setenv, tzset などの宣言を可視化(警告対策)

// diary_ver6.c - UTF-8対応・100文字制限・マルチバイト文字数カウント・ロケール堅牢化版
// Ver.5 → Ver.6 での大改編ポイント:複数行入力、UTF-8文字数カウント、20〜100文字制約、ロケール強化

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <locale.h>
#include <time.h>
#include <wchar.h>
#include <wctype.h>

#if defined(__linux__) || defined(__APPLE__)
  #include <langinfo.h>
  #define HAVE_LANGINFO 1
#endif

#define MAX_ENTRY   1000
#define MAX_LINE    1024
#define CONTENT_MAX (MAX_LINE * 10)

typedef struct {
    char content[CONTENT_MAX];
    char month[16];  // "YYYY-MM" または "未記録"
} DiaryEntry;

/* UTF-8 ロケールを確実に使うための初期化 */
static void init_locale_utf8(void) {
    const char *ok = NULL;

    // 環境のロケール(LANG/LC_*)を優先
    ok = setlocale(LC_ALL, "");
    // 代表的なUTF-8ロケールにフェイルオーバー
    if (!ok) ok = setlocale(LC_ALL, "ja_JP.UTF-8");
    if (!ok) ok = setlocale(LC_ALL, "C.UTF-8");
    if (!ok) ok = setlocale(LC_ALL, "en_US.UTF-8");

#if defined(HAVE_LANGINFO)
    if (ok) {
        const char *cs = nl_langinfo(CODESET);
        if (!cs || (strcmp(cs, "UTF-8") != 0 && strcmp(cs, "UTF8") != 0)) {
            fprintf(stderr,
                "警告: 現在のロケールはUTF-8ではありません(CODESET=%s)。"
                " 日本語の文字数カウントが正しく動作しない可能性があります。\n",
                cs ? cs : "(不明)");
        }
    } else {
        fprintf(stderr,
            "警告: UTF-8ロケールの設定に失敗しました。"
            " OS に ja_JP.UTF-8 / C.UTF-8 などを導入してください。\n");
    }
#else
    if (!ok) {
        fprintf(stderr,
            "警告: ロケールの設定に失敗しました(UTF-8未設定)。\n");
    }
#endif
}

/* マルチバイト文字を1文字としてカウント(改行除外) */
static size_t count_characters(const char *s) {
    size_t count = 0;
    wchar_t wc;
    mbstate_t state;
    memset(&state, 0, sizeof(state));

    while (*s) {
        size_t len = mbrtowc(&wc, s, MB_CUR_MAX, &state);
        if (len == (size_t)-1) { // 不正シーケンス:1バイト進めて継続
            ++s;
            memset(&state, 0, sizeof(state));
            continue;
        }
        if (len == (size_t)-2) { // 不完全(末尾)
            break;
        }
        if (len == 0) { // 終端
            break;
        }
        if (wc != L'\n' && wc != L'\r') count++;
        s += len;
    }
    return count;
}

/* 空行(空白/タブ/CRのみ + LF or 終端)を判定
   ※全角スペース(U+3000)は空白扱いにしていない点に注意 */
static int is_blank_line(const char *s) {
    const unsigned char *p = (const unsigned char *)s;
    while (*p == ' ' || *p == '\t' || *p == '\r') p++;
    return (*p == '\n' || *p == '\0');
}

static void make_timestamp(char *buf, size_t bufsize) {
    time_t now = time(NULL);
    struct tm *t = localtime(&now);
    const char *weekdays[] = {"日","月","火","水","木","金","土"};
    snprintf(buf, bufsize, "%04d-%02d-%02d(%s) %02d:%02d:%02d",
             t->tm_year + 1900, t->tm_mon + 1, t->tm_mday,
             weekdays[t->tm_wday], t->tm_hour, t->tm_min, t->tm_sec);
}

/* 先頭の [YYYY-MM-...] から YYYY-MM を安全に抽出 */
static void extract_month(const char *entry_text, char out[16]) {
    const char *start = strchr(entry_text, '[');
    if (!start) { strcpy(out, "未記録"); return; }

    char y[5] = {0}, m[3] = {0};
    if (sscanf(start + 1, "%4[0-9]-%2[0-9]", y, m) == 2) {
        snprintf(out, 16, "%s-%s", y, m);
    } else {
        strcpy(out, "未記録");
    }
}

int read_diary_entries(const char *filename, DiaryEntry *entries, int max_entries) {
    FILE *fp = fopen(filename, "r");
    if (!fp) return 0;

    char line[MAX_LINE];
    char buffer[CONTENT_MAX]; buffer[0] = '\0';
    int entry_count = 0;

    while (fgets(line, sizeof(line), fp)) {
        if (is_blank_line(line)) {
            if (buffer[0] != '\0' && entry_count < max_entries) {
                strncpy(entries[entry_count].content, buffer, CONTENT_MAX - 1);
                entries[entry_count].content[CONTENT_MAX - 1] = '\0';
                extract_month(buffer, entries[entry_count].month);
                entry_count++;
                buffer[0] = '\0';
            }
        } else {
            size_t cur = strlen(buffer);
            size_t rem = (CONTENT_MAX - 1) - cur;
            if (rem > 0) strncat(buffer, line, rem);
        }
    }

    if (buffer[0] != '\0' && entry_count < max_entries) {
        strncpy(entries[entry_count].content, buffer, CONTENT_MAX - 1);
        entries[entry_count].content[CONTENT_MAX - 1] = '\0';
        extract_month(buffer, entries[entry_count].month);
        entry_count++;
    }

    fclose(fp);
    return entry_count;
}

static void show_menu(void) {
    printf("\n********** MENU **********\n");
    printf("1. 過去2件の日記を表示する\n");
    printf("2. 新しい日記を書く(複数行)\n");
    printf("3. 月ごとの一覧を表示する\n");
    printf("9. 終了する\n");
    printf("***************************\n");
    printf("選択肢を入力してください:");
}

static void show_latest_two_entries(void) {
    DiaryEntry *entries = (DiaryEntry *)calloc(MAX_ENTRY, sizeof(DiaryEntry));
    if (!entries) { puts("メモリ確保に失敗しました。"); return; }

    int count = read_diary_entries("diary.txt", entries, MAX_ENTRY);

    printf("\n=== 過去2件(日付が新しい順) ===\n");
    if (count <= 0) {
        printf("(過去の日記はありません)\n");
        free(entries);
        return;
    }

    int start = (count >= 2) ? count - 2 : 0;
    for (int i = count - 1; i >= start; --i) {
        printf("%s\n", entries[i].content);
        if (i > start) puts("--------------------------------");
    }
    free(entries);
}

static void show_monthly_summary(void) {
    DiaryEntry *entries = (DiaryEntry *)calloc(MAX_ENTRY, sizeof(DiaryEntry));
    if (!entries) { puts("メモリ確保に失敗しました。"); return; }

    int count = read_diary_entries("diary.txt", entries, MAX_ENTRY);
    if (count <= 0) {
        printf("\n(日記がありません)\n");
        free(entries);
        return;
    }

    printf("\n=== 月ごとの一覧(古い→新しい) ===\n");
    char current_month[16] = "";
    for (int i = 0; i < count; i++) {
        if (strcmp(current_month, entries[i].month) != 0) {
            strncpy(current_month, entries[i].month, sizeof(current_month)-1);
            current_month[sizeof(current_month)-1] = '\0';
            printf("\n■ %s の日記:\n", current_month[0] ? current_month : "未記録");
        }
        printf("%s\n", entries[i].content);
    }
    free(entries);
}

static void write_new_entry(void) {
    char diary[CONTENT_MAX] = "";
    char line[MAX_LINE];

    printf("\n=== 今日の日記を入力してください(空行で終了) ===\n");
    while (fgets(line, sizeof(line), stdin)) {
        if (is_blank_line(line)) break;

        size_t cur_len = count_characters(diary);
        if (cur_len >= 100) {
            puts("※ すでに100文字に達したため、これ以上は記録されません。");
            break;
        }

        size_t remaining = 100 - cur_len;
        size_t copy_len = 0;
        wchar_t wc;
        mbstate_t state;
        memset(&state, 0, sizeof(state));
        const char *p = line;

        while (*p && remaining > 0) {
            size_t len = mbrtowc(&wc, p, MB_CUR_MAX, &state);
            if (len == (size_t)-1) { // 不正シーケンス:1バイト飛ばして継続
                ++p;
                memset(&state, 0, sizeof(state));
                continue;
            }
            if (len == (size_t)-2 || len == 0) { // 不完全/終端
                break;
            }
            if (wc != L'\n' && wc != L'\r') remaining--;
            copy_len += len;
            p += len;
        }

        strncat(diary, line, copy_len);
        printf("(現在 %zu 文字 / 最大100文字)\n", count_characters(diary));
    }

    size_t len = count_characters(diary);
    if (len < 20) {
        puts("内容が短すぎます。20文字以上で入力してください。");
        return;
    }

    char timestamp[128];
    make_timestamp(timestamp, sizeof(timestamp));

    FILE *fp = fopen("diary.txt", "a");
    if (!fp) {
        perror("ファイルを開けませんでした");
        return;
    }

    fprintf(fp, "[%s]\n%s\n\n", timestamp, diary);
    fclose(fp);
    puts("日記を保存しました。");
}

int main(void) {
    setenv("TZ", "Asia/Tokyo", 1);
    tzset();
    init_locale_utf8();  // UTF-8 ロケールを確保

    int choice;
    for (;;) {
        show_menu();

        if (scanf("%d", &choice) != 1) {
            puts("数字を入力してください。");
            int ch; while ((ch = getchar()) != '\n' && ch != EOF) {}
            continue;
        }
        int ch; while ((ch = getchar()) != '\n' && ch != EOF) {}

        switch (choice) {
            case 1: show_latest_two_entries(); break;
            case 2: write_new_entry(); break;
            case 3: show_monthly_summary(); break;
            case 9: puts("終了します。"); return 0;
            default: puts("無効な選択です。もう一度入力してください。");
        }
    }
    return 0;
}

コンパイル方法(変更点あり)
文字カウント仕様の追加に伴い、POSIX 関数(setenv, tzset)の宣言を確実に可視化するため、
マクロ定義を付けてビルドすることを推奨する。

推奨コマンド(Linux / macOS / PaizaCloud)

gcc -std=c11 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -O2 diary_ver6.c -o diary

代替:-D_XOPEN_SOURCE=700 でも同等の効果がある。
macOS(clang)でも同じ指定で OK。

実行環境に関する補足(PaizaCloud/Ubuntu 18 系)
PaizaCloud(Ubuntu 18 系)では、デフォルトで en_US.UTF-8 が有効(UTF-8)。
locale の出力が *.UTF-8 なら、mbrtowc による文字数カウントは正常動作する。

実行例

=== 今日の日記を入力してください(空行で終了) ===
今日はめちゃくちゃ暑かった。
(現在 14 文字 / 最大100文字)
明日こそ涼しくなってほしい。
(現在 28 文字 / 最大100文字)
でもどうなるかな?
(現在 37 文字 / 最大100文字)

日記を保存しました。
=== 過去2件(日付が新しい順) ===
[2025-08-23(土) 12:55:39]
今日はめちゃくちゃ暑かった。
明日こそ涼しくなってほしい。
でもどうなるかな?

互換性と移行メモ(Ver.5 → Ver.6)

  • 保存形式は互換:[timestamp] → 本文 → 空行 の構造を継承。既存 diary.txt はそのまま利用可能である。
  • 文字数の扱い:Ver.6 では「UTF-8 の“文字”」としてカウント(改行は除外)する。
  • 入力 UI:1行 → 複数行(空行で終了) へ。追記時に「現在文字数」を表示する。
  • 20〜100文字の範囲内で保存。
  • ロケール:固定 "ja_JP.UTF-8" 依存から脱却し、環境に合わせて UTF-8 を確保(フォールバック内蔵)。

よくある質問(FAQ)

Q1. 「(現在 0 文字)」のまま増えない。
A. 端末/プロセスのロケールが UTF-8 になっていない可能性。locale*.UTF-8 を確認。
Ver.6 は init_locale_utf8() で UTF-8 を確保するが、OS 側に UTF-8 ロケールが未導入の場合は導入が必要。

Q2. setenv / tzset に警告が出る。
A.POSIX マクロを付けてビルドしてください。
gcc -std=c11 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -O2 ...

Q3. Windows(MSVC)でビルドしたい。
A. 本記事は Linux / macOS 前提である。

Q4. 全角スペースだけの行を「空行」とみなしたい。
A. 現状は半角空白/タブ/CR のみを空白扱い。


コメント

タイトルとURLをコピーしました